From b18733bce02ddfbcb4c99051a253cfccc2f17740 Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Tue, 21 May 2019 15:07:36 +0200 Subject: [PATCH 1/2] added the possibility to register classes/interfaces as being safe for the escaper --- CHANGELOG | 1 + doc/api.rst | 18 + src/Extension/EscaperExtension.php | 469 ++++++++++++---------- test/Twig/Tests/Extension/EscaperTest.php | 71 +++- test/Twig/Tests/IntegrationTest.php | 3 +- 5 files changed, 334 insertions(+), 228 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 908bc30050..977cfb9c9e 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,6 @@ * 2.11.0 (2019-XX-XX) + * added the possibility to register classes/interfaces as being safe for the escaper ("EscaperExtension::addSafeClass()") * deprecated CoreExtension::setEscaper() and CoreExtension::getEscapers() in favor of the same methods on EscaperExtension * macros are now auto-imported in the template they are defined (under the ``_self`` variable) * added support for macros on "is defined" tests diff --git a/doc/api.rst b/doc/api.rst index 0f0abcee95..5c0294c20b 100644 --- a/doc/api.rst +++ b/doc/api.rst @@ -415,6 +415,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/EscaperExtension.php b/src/Extension/EscaperExtension.php index 497cfde20f..2ccab8b2f3 100644 --- a/src/Extension/EscaperExtension.php +++ b/src/Extension/EscaperExtension.php @@ -14,11 +14,17 @@ use Twig\NodeVisitor\EscaperNodeVisitor; use Twig\TokenParser\AutoEscapeTokenParser; use Twig\TwigFilter; +use Twig\Environment; +use Twig\Error\RuntimeError; +use Twig\Extension\CoreExtension; +use Twig\Markup; final class EscaperExtension extends AbstractExtension { private $defaultStrategy; private $escapers = []; + private $safeClasses = []; + private $safeLookup = []; /** * @param string|false|callable $defaultStrategy An escaping strategy @@ -43,8 +49,8 @@ public function getNodeVisitors() public function getFilters() { return [ - new TwigFilter('escape', 'twig_escape_filter', ['needs_environment' => true, 'is_safe_callback' => 'twig_escape_filter_is_safe']), - new TwigFilter('e', 'twig_escape_filter', ['needs_environment' => true, 'is_safe_callback' => 'twig_escape_filter_is_safe']), + new TwigFilter('escape', [$this, 'escape'], ['needs_environment' => true, 'is_safe_callback' => 'twig_escape_filter_is_safe']), + new TwigFilter('e', [$this, 'escape'], ['needs_environment' => true, 'is_safe_callback' => 'twig_escape_filter_is_safe']), new TwigFilter('raw', 'twig_raw_filter', ['is_safe' => ['all']]), ]; } @@ -104,262 +110,309 @@ public function getEscapers() { return $this->escapers; } -} -class_alias('Twig\Extension\EscaperExtension', 'Twig_Extension_Escaper'); -} - -namespace { -use Twig\Environment; -use Twig\Error\RuntimeError; -use Twig\Extension\CoreExtension; -use Twig\Extension\EscaperExtension; -use Twig\Markup; -use Twig\Node\Expression\ConstantExpression; -use Twig\Node\Node; + public function setSafeClasses(array $safeClasses = []) + { + $this->safeClasses = []; + $this->safeLookup = []; + foreach ($safeClasses as $class => $strategies) { + $this->addSafeClass($class, $strategies); + } + } -/** - * Marks a variable as being safe. - * - * @param string $string A PHP variable - * - * @return string - */ -function twig_raw_filter($string) -{ - return $string; -} + public function addSafeClass(string $class, array $strategies) + { + $class = ltrim($class, '\\'); + if (!isset($this->safeClasses[$class])) { + $this->safeClasses[$class] = []; + } + $this->safeClasses[$class] = array_merge($this->safeClasses[$class], $strategies); -/** - * Escapes a string. - * - * @param mixed $string The value to be escaped - * @param string $strategy The escaping strategy - * @param string $charset The charset - * @param bool $autoescape Whether the function is called by the auto-escaping feature (true) or by the developer (false) - * - * @return string - */ -function twig_escape_filter(Environment $env, $string, $strategy = 'html', $charset = null, $autoescape = false) -{ - if ($autoescape && $string instanceof Markup) { - return $string; + foreach ($strategies as $strategy) { + $this->safeLookup[$strategy][$class] = true; + } } - if (!\is_string($string)) { - if (\is_object($string) && method_exists($string, '__toString')) { - $string = (string) $string; - } elseif (\in_array($strategy, ['html', 'js', 'css', 'html_attr', 'url'])) { + /** + * Escapes a string. + * + * @param mixed $string The value to be escaped + * @param string $strategy The escaping strategy + * @param string $charset The charset + * @param bool $autoescape Whether the function is called by the auto-escaping feature (true) or by the developer (false) + * + * @return string + */ + function escape(Environment $env, $string, $strategy = 'html', $charset = null, $autoescape = false) + { + if ($autoescape && $string instanceof Markup) { return $string; } - } - - if ('' === $string) { - return ''; - } - if (null === $charset) { - $charset = $env->getCharset(); - } + if (!\is_string($string)) { + if (\is_object($string) && method_exists($string, '__toString')) { + if ($autoescape) { + $c = get_class($string); + if (!isset($this->safeClasses[$c])) { + $this->safeClasses[$c] = []; + foreach (class_parents($string) + class_implements($string) as $class) { + if (isset($this->safeClasses[$class])) { + $this->safeClasses[$c] = array_unique(array_merge($this->safeClasses[$c], $this->safeClasses[$class])); + foreach ($this->safeClasses[$class] as $s) { + $this->safeLookup[$s][$c] = true; + } + } + } + } + if (isset($this->safeLookup[$strategy][$c]) || isset($this->safeLookup['all'][$c])) { + return (string) $string; + } + } - switch ($strategy) { - case 'html': - // see https://secure.php.net/htmlspecialchars - - // Using a static variable to avoid initializing the array - // each time the function is called. Moving the declaration on the - // top of the function slow downs other escaping strategies. - static $htmlspecialcharsCharsets = [ - 'ISO-8859-1' => true, 'ISO8859-1' => true, - 'ISO-8859-15' => true, 'ISO8859-15' => true, - 'utf-8' => true, 'UTF-8' => true, - 'CP866' => true, 'IBM866' => true, '866' => true, - 'CP1251' => true, 'WINDOWS-1251' => true, 'WIN-1251' => true, - '1251' => true, - 'CP1252' => true, 'WINDOWS-1252' => true, '1252' => true, - 'KOI8-R' => true, 'KOI8-RU' => true, 'KOI8R' => true, - 'BIG5' => true, '950' => true, - 'GB2312' => true, '936' => true, - 'BIG5-HKSCS' => true, - 'SHIFT_JIS' => true, 'SJIS' => true, '932' => true, - 'EUC-JP' => true, 'EUCJP' => true, - 'ISO8859-5' => true, 'ISO-8859-5' => true, 'MACROMAN' => true, - ]; - - if (isset($htmlspecialcharsCharsets[$charset])) { - return htmlspecialchars($string, ENT_QUOTES | ENT_SUBSTITUTE, $charset); + $string = (string) $string; + } elseif (\in_array($strategy, ['html', 'js', 'css', 'html_attr', 'url'])) { + return $string; } + } - if (isset($htmlspecialcharsCharsets[strtoupper($charset)])) { - // cache the lowercase variant for future iterations - $htmlspecialcharsCharsets[$charset] = true; + if ('' === $string) { + return ''; + } - return htmlspecialchars($string, ENT_QUOTES | ENT_SUBSTITUTE, $charset); - } + if (null === $charset) { + $charset = $env->getCharset(); + } + + switch ($strategy) { + case 'html': + // see https://secure.php.net/htmlspecialchars + + // Using a static variable to avoid initializing the array + // each time the function is called. Moving the declaration on the + // top of the function slow downs other escaping strategies. + static $htmlspecialcharsCharsets = [ + 'ISO-8859-1' => true, 'ISO8859-1' => true, + 'ISO-8859-15' => true, 'ISO8859-15' => true, + 'utf-8' => true, 'UTF-8' => true, + 'CP866' => true, 'IBM866' => true, '866' => true, + 'CP1251' => true, 'WINDOWS-1251' => true, 'WIN-1251' => true, + '1251' => true, + 'CP1252' => true, 'WINDOWS-1252' => true, '1252' => true, + 'KOI8-R' => true, 'KOI8-RU' => true, 'KOI8R' => true, + 'BIG5' => true, '950' => true, + 'GB2312' => true, '936' => true, + 'BIG5-HKSCS' => true, + 'SHIFT_JIS' => true, 'SJIS' => true, '932' => true, + 'EUC-JP' => true, 'EUCJP' => true, + 'ISO8859-5' => true, 'ISO-8859-5' => true, 'MACROMAN' => true, + ]; + + if (isset($htmlspecialcharsCharsets[$charset])) { + return htmlspecialchars($string, ENT_QUOTES | ENT_SUBSTITUTE, $charset); + } - $string = iconv($charset, 'UTF-8', $string); - $string = htmlspecialchars($string, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + if (isset($htmlspecialcharsCharsets[strtoupper($charset)])) { + // cache the lowercase variant for future iterations + $htmlspecialcharsCharsets[$charset] = true; - return iconv('UTF-8', $charset, $string); + return htmlspecialchars($string, ENT_QUOTES | ENT_SUBSTITUTE, $charset); + } - case 'js': - // escape all non-alphanumeric characters - // into their \x or \uHHHH representations - if ('UTF-8' !== $charset) { $string = iconv($charset, 'UTF-8', $string); - } + $string = htmlspecialchars($string, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); - if (!preg_match('//u', $string)) { - throw new RuntimeError('The string to escape is not a valid UTF-8 string.'); - } + return iconv('UTF-8', $charset, $string); - $string = preg_replace_callback('#[^a-zA-Z0-9,\._]#Su', function ($matches) { - $char = $matches[0]; - - /* - * A few characters have short escape sequences in JSON and JavaScript. - * Escape sequences supported only by JavaScript, not JSON, are ommitted. - * \" is also supported but omitted, because the resulting string is not HTML safe. - */ - static $shortMap = [ - '\\' => '\\\\', - '/' => '\\/', - "\x08" => '\b', - "\x0C" => '\f', - "\x0A" => '\n', - "\x0D" => '\r', - "\x09" => '\t', - ]; + case 'js': + // escape all non-alphanumeric characters + // into their \x or \uHHHH representations + if ('UTF-8' !== $charset) { + $string = iconv($charset, 'UTF-8', $string); + } - if (isset($shortMap[$char])) { - return $shortMap[$char]; + if (!preg_match('//u', $string)) { + throw new RuntimeError('The string to escape is not a valid UTF-8 string.'); } - // \uHHHH - $char = twig_convert_encoding($char, 'UTF-16BE', 'UTF-8'); - $char = strtoupper(bin2hex($char)); + $string = preg_replace_callback('#[^a-zA-Z0-9,\._]#Su', function ($matches) { + $char = $matches[0]; - if (4 >= \strlen($char)) { - return sprintf('\u%04s', $char); - } + /* + * A few characters have short escape sequences in JSON and JavaScript. + * Escape sequences supported only by JavaScript, not JSON, are ommitted. + * \" is also supported but omitted, because the resulting string is not HTML safe. + */ + static $shortMap = [ + '\\' => '\\\\', + '/' => '\\/', + "\x08" => '\b', + "\x0C" => '\f', + "\x0A" => '\n', + "\x0D" => '\r', + "\x09" => '\t', + ]; - return sprintf('\u%04s\u%04s', substr($char, 0, -4), substr($char, -4)); - }, $string); + if (isset($shortMap[$char])) { + return $shortMap[$char]; + } - if ('UTF-8' !== $charset) { - $string = iconv('UTF-8', $charset, $string); - } + // \uHHHH + $char = twig_convert_encoding($char, 'UTF-16BE', 'UTF-8'); + $char = strtoupper(bin2hex($char)); - return $string; + if (4 >= \strlen($char)) { + return sprintf('\u%04s', $char); + } - case 'css': - if ('UTF-8' !== $charset) { - $string = iconv($charset, 'UTF-8', $string); - } + return sprintf('\u%04s\u%04s', substr($char, 0, -4), substr($char, -4)); + }, $string); - if (!preg_match('//u', $string)) { - throw new RuntimeError('The string to escape is not a valid UTF-8 string.'); - } + if ('UTF-8' !== $charset) { + $string = iconv('UTF-8', $charset, $string); + } - $string = preg_replace_callback('#[^a-zA-Z0-9]#Su', function ($matches) { - $char = $matches[0]; + return $string; - return sprintf('\\%X ', 1 === \strlen($char) ? \ord($char) : mb_ord($char, 'UTF-8')); - }, $string); + case 'css': + if ('UTF-8' !== $charset) { + $string = iconv($charset, 'UTF-8', $string); + } - if ('UTF-8' !== $charset) { - $string = iconv('UTF-8', $charset, $string); - } + if (!preg_match('//u', $string)) { + throw new RuntimeError('The string to escape is not a valid UTF-8 string.'); + } - return $string; + $string = preg_replace_callback('#[^a-zA-Z0-9]#Su', function ($matches) { + $char = $matches[0]; - case 'html_attr': - if ('UTF-8' !== $charset) { - $string = iconv($charset, 'UTF-8', $string); - } + return sprintf('\\%X ', 1 === \strlen($char) ? \ord($char) : mb_ord($char, 'UTF-8')); + }, $string); - if (!preg_match('//u', $string)) { - throw new RuntimeError('The string to escape is not a valid UTF-8 string.'); - } + if ('UTF-8' !== $charset) { + $string = iconv('UTF-8', $charset, $string); + } + + return $string; - $string = preg_replace_callback('#[^a-zA-Z0-9,\.\-_]#Su', function ($matches) { - /** - * This function is adapted from code coming from Zend Framework. - * - * @copyright Copyright (c) 2005-2012 Zend Technologies USA Inc. (https://www.zend.com) - * @license https://framework.zend.com/license/new-bsd New BSD License - */ - $chr = $matches[0]; - $ord = \ord($chr); - - /* - * The following replaces characters undefined in HTML with the - * hex entity for the Unicode replacement character. - */ - if (($ord <= 0x1f && "\t" != $chr && "\n" != $chr && "\r" != $chr) || ($ord >= 0x7f && $ord <= 0x9f)) { - return '�'; + case 'html_attr': + if ('UTF-8' !== $charset) { + $string = iconv($charset, 'UTF-8', $string); } - /* - * Check if the current character to escape has a name entity we should - * replace it with while grabbing the hex value of the character. - */ - if (1 === \strlen($chr)) { - /* - * While HTML supports far more named entities, the lowest common denominator - * has become HTML5's XML Serialisation which is restricted to the those named - * entities that XML supports. Using HTML entities would result in this error: - * XML Parsing Error: undefined entity + if (!preg_match('//u', $string)) { + throw new RuntimeError('The string to escape is not a valid UTF-8 string.'); + } + + $string = preg_replace_callback('#[^a-zA-Z0-9,\.\-_]#Su', function ($matches) { + /** + * This function is adapted from code coming from Zend Framework. + * + * @copyright Copyright (c) 2005-2012 Zend Technologies USA Inc. (https://www.zend.com) + * @license https://framework.zend.com/license/new-bsd New BSD License */ - static $entityMap = [ - 34 => '"', /* quotation mark */ - 38 => '&', /* ampersand */ - 60 => '<', /* less-than sign */ - 62 => '>', /* greater-than sign */ - ]; + $chr = $matches[0]; + $ord = \ord($chr); - if (isset($entityMap[$ord])) { - return $entityMap[$ord]; + /* + * The following replaces characters undefined in HTML with the + * hex entity for the Unicode replacement character. + */ + if (($ord <= 0x1f && "\t" != $chr && "\n" != $chr && "\r" != $chr) || ($ord >= 0x7f && $ord <= 0x9f)) { + return '�'; + } + + /* + * Check if the current character to escape has a name entity we should + * replace it with while grabbing the hex value of the character. + */ + if (1 === \strlen($chr)) { + /* + * While HTML supports far more named entities, the lowest common denominator + * has become HTML5's XML Serialisation which is restricted to the those named + * entities that XML supports. Using HTML entities would result in this error: + * XML Parsing Error: undefined entity + */ + static $entityMap = [ + 34 => '"', /* quotation mark */ + 38 => '&', /* ampersand */ + 60 => '<', /* less-than sign */ + 62 => '>', /* greater-than sign */ + ]; + + if (isset($entityMap[$ord])) { + return $entityMap[$ord]; + } + + return sprintf('&#x%02X;', $ord); } - return sprintf('&#x%02X;', $ord); + /* + * Per OWASP recommendations, we'll use hex entities for any other + * characters where a named entity does not exist. + */ + return sprintf('&#x%04X;', mb_ord($chr, 'UTF-8')); + }, $string); + + if ('UTF-8' !== $charset) { + $string = iconv('UTF-8', $charset, $string); } - /* - * Per OWASP recommendations, we'll use hex entities for any other - * characters where a named entity does not exist. - */ - return sprintf('&#x%04X;', mb_ord($chr, 'UTF-8')); - }, $string); + return $string; - if ('UTF-8' !== $charset) { - $string = iconv('UTF-8', $charset, $string); - } + case 'url': + return rawurlencode($string); - return $string; + default: + static $escapers; + + if (null === $escapers) { + // merge the ones set on CoreExtension for BC (to be removed in 3.0) + $escapers = array_merge( + $env->getExtension(CoreExtension::class)->getEscapers(false), + $env->getExtension(EscaperExtension::class)->getEscapers() + ); + } - case 'url': - return rawurlencode($string); + if (isset($escapers[$strategy])) { + return $escapers[$strategy]($env, $string, $charset); + } - default: - static $escapers; + $validStrategies = implode(', ', array_merge(['html', 'js', 'url', 'css', 'html_attr'], array_keys($escapers))); - if (null === $escapers) { - // merge the ones set on CoreExtension for BC (to be removed in 3.0) - $escapers = array_merge( - $env->getExtension(CoreExtension::class)->getEscapers(false), - $env->getExtension(EscaperExtension::class)->getEscapers() - ); - } + throw new RuntimeError(sprintf('Invalid escaping strategy "%s" (valid ones: %s).', $strategy, $validStrategies)); + } + } +} - if (isset($escapers[$strategy])) { - return $escapers[$strategy]($env, $string, $charset); - } +class_alias('Twig\Extension\EscaperExtension', 'Twig_Extension_Escaper'); +} - $validStrategies = implode(', ', array_merge(['html', 'js', 'url', 'css', 'html_attr'], array_keys($escapers))); +namespace { +use Twig\Environment; +use Twig\Extension\EscaperExtension; +use Twig\Node\Expression\ConstantExpression; +use Twig\Node\Node; - throw new RuntimeError(sprintf('Invalid escaping strategy "%s" (valid ones: %s).', $strategy, $validStrategies)); - } +/** + * Marks a variable as being safe. + * + * @param string $string A PHP variable + * + * @return string + */ +function twig_raw_filter($string) +{ + return $string; +} + +/** + * @deprecated since Twig 2.11, to be removed in 3.0; use EscaperExtension::escape instead + */ +function twig_escape_filter(Environment $env, $string, $strategy = 'html', $charset = null, $autoescape = false) +{ + @trigger_error(sprintf('The "%s" method is deprecated since Twig 2.11; use "%s::escape" instead.', __METHOD__, EscaperExtension::class), E_USER_DEPRECATED); + + return $env->getExtension(EscaperExtension::class)->escape($env, $string, $strategy, $charset, $autoescape); } /** diff --git a/test/Twig/Tests/Extension/EscaperTest.php b/test/Twig/Tests/Extension/EscaperTest.php index 78c05b1f2d..88143d9be9 100644 --- a/test/Twig/Tests/Extension/EscaperTest.php +++ b/test/Twig/Tests/Extension/EscaperTest.php @@ -158,7 +158,7 @@ public function testHtmlEscapingConvertsSpecialChars() { $twig = new \Twig\Environment($this->getMockBuilder(\Twig\Loader\LoaderInterface::class)->getMock()); foreach ($this->htmlSpecialChars as $key => $value) { - $this->assertEquals($value, twig_escape_filter($twig, $key, 'html'), 'Failed to escape: '.$key); + $this->assertEquals($value, $twig->getExtension(EscaperExtension::class)->escape($twig, $key, 'html'), 'Failed to escape: '.$key); } } @@ -166,7 +166,7 @@ public function testHtmlAttributeEscapingConvertsSpecialChars() { $twig = new \Twig\Environment($this->getMockBuilder(\Twig\Loader\LoaderInterface::class)->getMock()); foreach ($this->htmlAttrSpecialChars as $key => $value) { - $this->assertEquals($value, twig_escape_filter($twig, $key, 'html_attr'), 'Failed to escape: '.$key); + $this->assertEquals($value, $twig->getExtension(EscaperExtension::class)->escape($twig, $key, 'html_attr'), 'Failed to escape: '.$key); } } @@ -174,47 +174,47 @@ public function testJavascriptEscapingConvertsSpecialChars() { $twig = new \Twig\Environment($this->getMockBuilder(\Twig\Loader\LoaderInterface::class)->getMock()); foreach ($this->jsSpecialChars as $key => $value) { - $this->assertEquals($value, twig_escape_filter($twig, $key, 'js'), 'Failed to escape: '.$key); + $this->assertEquals($value, $twig->getExtension(EscaperExtension::class)->escape($twig, $key, 'js'), 'Failed to escape: '.$key); } } public function testJavascriptEscapingReturnsStringIfZeroLength() { $twig = new \Twig\Environment($this->getMockBuilder(\Twig\Loader\LoaderInterface::class)->getMock()); - $this->assertEquals('', twig_escape_filter($twig, '', 'js')); + $this->assertEquals('', $twig->getExtension(EscaperExtension::class)->escape($twig, '', 'js')); } public function testJavascriptEscapingReturnsStringIfContainsOnlyDigits() { $twig = new \Twig\Environment($this->getMockBuilder(\Twig\Loader\LoaderInterface::class)->getMock()); - $this->assertEquals('123', twig_escape_filter($twig, '123', 'js')); + $this->assertEquals('123', $twig->getExtension(EscaperExtension::class)->escape($twig, '123', 'js')); } public function testCssEscapingConvertsSpecialChars() { $twig = new \Twig\Environment($this->getMockBuilder(\Twig\Loader\LoaderInterface::class)->getMock()); foreach ($this->cssSpecialChars as $key => $value) { - $this->assertEquals($value, twig_escape_filter($twig, $key, 'css'), 'Failed to escape: '.$key); + $this->assertEquals($value, $twig->getExtension(EscaperExtension::class)->escape($twig, $key, 'css'), 'Failed to escape: '.$key); } } public function testCssEscapingReturnsStringIfZeroLength() { $twig = new \Twig\Environment($this->getMockBuilder(\Twig\Loader\LoaderInterface::class)->getMock()); - $this->assertEquals('', twig_escape_filter($twig, '', 'css')); + $this->assertEquals('', $twig->getExtension(EscaperExtension::class)->escape($twig, '', 'css')); } public function testCssEscapingReturnsStringIfContainsOnlyDigits() { $twig = new \Twig\Environment($this->getMockBuilder(\Twig\Loader\LoaderInterface::class)->getMock()); - $this->assertEquals('123', twig_escape_filter($twig, '123', 'css')); + $this->assertEquals('123', $twig->getExtension(EscaperExtension::class)->escape($twig, '123', 'css')); } public function testUrlEscapingConvertsSpecialChars() { $twig = new \Twig\Environment($this->getMockBuilder(\Twig\Loader\LoaderInterface::class)->getMock()); foreach ($this->urlSpecialChars as $key => $value) { - $this->assertEquals($value, twig_escape_filter($twig, $key, 'url'), 'Failed to escape: '.$key); + $this->assertEquals($value, $twig->getExtension(EscaperExtension::class)->escape($twig, $key, 'url'), 'Failed to escape: '.$key); } } @@ -276,15 +276,15 @@ public function testJavascriptEscapingEscapesOwaspRecommendedRanges() || $chr >= 0x41 && $chr <= 0x5A || $chr >= 0x61 && $chr <= 0x7A) { $literal = $this->codepointToUtf8($chr); - $this->assertEquals($literal, twig_escape_filter($twig, $literal, 'js')); + $this->assertEquals($literal, $twig->getExtension(EscaperExtension::class)->escape($twig, $literal, 'js')); } else { $literal = $this->codepointToUtf8($chr); if (\in_array($literal, $immune)) { - $this->assertEquals($literal, twig_escape_filter($twig, $literal, 'js')); + $this->assertEquals($literal, $twig->getExtension(EscaperExtension::class)->escape($twig, $literal, 'js')); } else { $this->assertNotEquals( $literal, - twig_escape_filter($twig, $literal, 'js'), + $twig->getExtension(EscaperExtension::class)->escape($twig, $literal, 'js'), "$literal should be escaped!"); } } @@ -300,15 +300,15 @@ public function testHtmlAttributeEscapingEscapesOwaspRecommendedRanges() || $chr >= 0x41 && $chr <= 0x5A || $chr >= 0x61 && $chr <= 0x7A) { $literal = $this->codepointToUtf8($chr); - $this->assertEquals($literal, twig_escape_filter($twig, $literal, 'html_attr')); + $this->assertEquals($literal, $twig->getExtension(EscaperExtension::class)->escape($twig, $literal, 'html_attr')); } else { $literal = $this->codepointToUtf8($chr); if (\in_array($literal, $immune)) { - $this->assertEquals($literal, twig_escape_filter($twig, $literal, 'html_attr')); + $this->assertEquals($literal, $twig->getExtension(EscaperExtension::class)->escape($twig, $literal, 'html_attr')); } else { $this->assertNotEquals( $literal, - twig_escape_filter($twig, $literal, 'html_attr'), + $twig->getExtension(EscaperExtension::class)->escape($twig, $literal, 'html_attr'), "$literal should be escaped!"); } } @@ -324,12 +324,12 @@ public function testCssEscapingEscapesOwaspRecommendedRanges() || $chr >= 0x41 && $chr <= 0x5A || $chr >= 0x61 && $chr <= 0x7A) { $literal = $this->codepointToUtf8($chr); - $this->assertEquals($literal, twig_escape_filter($twig, $literal, 'css')); + $this->assertEquals($literal, $twig->getExtension(EscaperExtension::class)->escape($twig, $literal, 'css')); } else { $literal = $this->codepointToUtf8($chr); $this->assertNotEquals( $literal, - twig_escape_filter($twig, $literal, 'css'), + $twig->getExtension(EscaperExtension::class)->escape($twig, $literal, 'css'), "$literal should be escaped!"); } } @@ -343,7 +343,7 @@ public function testCustomEscaper($expected, $string, $strategy) $twig = new Environment($this->getMockBuilder(LoaderInterface::class)->getMock()); $twig->getExtension(EscaperExtension::class)->setEscaper('foo', 'foo_escaper_for_test'); - $this->assertSame($expected, twig_escape_filter($twig, $string, $strategy)); + $this->assertSame($expected, $twig->getExtension(EscaperExtension::class)->escape($twig, $string, $strategy)); } public function provideCustomEscaperCases() @@ -360,7 +360,29 @@ public function provideCustomEscaperCases() */ public function testUnknownCustomEscaper() { - twig_escape_filter(new Environment($this->getMockBuilder(LoaderInterface::class)->getMock()), 'foo', 'bar'); + $twig = new Environment($this->getMockBuilder(LoaderInterface::class)->getMock()); + $twig->getExtension(EscaperExtension::class)->escape($twig, 'foo', 'bar'); + } + + /** + * @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->getExtension(EscaperExtension::class)->escape($twig, $obj, 'html', null, true)); + $this->assertSame($escapedJs, $twig->getExtension(EscaperExtension::class)->escape($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']]], + ]; } } @@ -368,3 +390,14 @@ function foo_escaper_for_test(Environment $twig, $string, $charset) { return $string.$charset; } + +interface Twig_Tests_Extension_SafeHtmlInterface +{ +} +class Twig_Tests_Extension_TestClass implements Twig_Tests_Extension_SafeHtmlInterface +{ + public function __toString() + { + return '
'; + } +} diff --git a/test/Twig/Tests/IntegrationTest.php b/test/Twig/Tests/IntegrationTest.php index 6bd727b1ed..90a7099a77 100644 --- a/test/Twig/Tests/IntegrationTest.php +++ b/test/Twig/Tests/IntegrationTest.php @@ -22,6 +22,7 @@ use Twig\TwigFilter; use Twig\TwigFunction; use Twig\TwigTest; +use Twig\Extension\EscaperExtension; // This function is defined to check that escaping strategies // like html works even if a function with the same name is defined. @@ -203,7 +204,7 @@ public function §Function($value) */ public function escape_and_nl2br($env, $value, $sep = '
') { - return $this->nl2br(twig_escape_filter($env, $value, 'html'), $sep); + return $this->nl2br($env->getExtension(EscaperExtension::class)->escape($env, $value, 'html'), $sep); } /** From fe6503fe02b824ca86dcb6984497061b7cbfaf69 Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Tue, 21 May 2019 16:03:11 +0200 Subject: [PATCH 2/2] - --- src/Extension/EscaperExtension.php | 472 +++++++++++----------- test/Twig/Tests/Extension/EscaperTest.php | 44 +- test/Twig/Tests/IntegrationTest.php | 3 +- 3 files changed, 256 insertions(+), 263 deletions(-) diff --git a/src/Extension/EscaperExtension.php b/src/Extension/EscaperExtension.php index 2ccab8b2f3..b8dabccf11 100644 --- a/src/Extension/EscaperExtension.php +++ b/src/Extension/EscaperExtension.php @@ -14,17 +14,17 @@ use Twig\NodeVisitor\EscaperNodeVisitor; use Twig\TokenParser\AutoEscapeTokenParser; use Twig\TwigFilter; -use Twig\Environment; -use Twig\Error\RuntimeError; -use Twig\Extension\CoreExtension; -use Twig\Markup; final class EscaperExtension extends AbstractExtension { private $defaultStrategy; private $escapers = []; - private $safeClasses = []; - private $safeLookup = []; + + /** @internal */ + public $safeClasses = []; + + /** @internal */ + public $safeLookup = []; /** * @param string|false|callable $defaultStrategy An escaping strategy @@ -49,8 +49,8 @@ public function getNodeVisitors() public function getFilters() { return [ - new TwigFilter('escape', [$this, 'escape'], ['needs_environment' => true, 'is_safe_callback' => 'twig_escape_filter_is_safe']), - new TwigFilter('e', [$this, 'escape'], ['needs_environment' => true, 'is_safe_callback' => 'twig_escape_filter_is_safe']), + new TwigFilter('escape', 'twig_escape_filter', ['needs_environment' => true, 'is_safe_callback' => 'twig_escape_filter_is_safe']), + new TwigFilter('e', 'twig_escape_filter', ['needs_environment' => true, 'is_safe_callback' => 'twig_escape_filter_is_safe']), new TwigFilter('raw', 'twig_raw_filter', ['is_safe' => ['all']]), ]; } @@ -132,287 +132,281 @@ public function addSafeClass(string $class, array $strategies) $this->safeLookup[$strategy][$class] = true; } } +} - /** - * Escapes a string. - * - * @param mixed $string The value to be escaped - * @param string $strategy The escaping strategy - * @param string $charset The charset - * @param bool $autoescape Whether the function is called by the auto-escaping feature (true) or by the developer (false) - * - * @return string - */ - function escape(Environment $env, $string, $strategy = 'html', $charset = null, $autoescape = false) - { - if ($autoescape && $string instanceof Markup) { - return $string; - } +class_alias('Twig\Extension\EscaperExtension', 'Twig_Extension_Escaper'); +} + +namespace { +use Twig\Environment; +use Twig\Error\RuntimeError; +use Twig\Extension\CoreExtension; +use Twig\Extension\EscaperExtension; +use Twig\Markup; +use Twig\Node\Expression\ConstantExpression; +use Twig\Node\Node; + +/** + * Marks a variable as being safe. + * + * @param string $string A PHP variable + * + * @return string + */ +function twig_raw_filter($string) +{ + return $string; +} - if (!\is_string($string)) { - if (\is_object($string) && method_exists($string, '__toString')) { - if ($autoescape) { - $c = get_class($string); - if (!isset($this->safeClasses[$c])) { - $this->safeClasses[$c] = []; - foreach (class_parents($string) + class_implements($string) as $class) { - if (isset($this->safeClasses[$class])) { - $this->safeClasses[$c] = array_unique(array_merge($this->safeClasses[$c], $this->safeClasses[$class])); - foreach ($this->safeClasses[$class] as $s) { - $this->safeLookup[$s][$c] = true; - } +/** + * Escapes a string. + * + * @param mixed $string The value to be escaped + * @param string $strategy The escaping strategy + * @param string $charset The charset + * @param bool $autoescape Whether the function is called by the auto-escaping feature (true) or by the developer (false) + * + * @return string + */ +function twig_escape_filter(Environment $env, $string, $strategy = 'html', $charset = null, $autoescape = false) +{ + if ($autoescape && $string instanceof Markup) { + return $string; + } + + if (!\is_string($string)) { + if (\is_object($string) && method_exists($string, '__toString')) { + if ($autoescape) { + $c = \get_class($string); + $ext = $env->getExtension(EscaperExtension::class); + if (!isset($ext->safeClasses[$c])) { + $ext->safeClasses[$c] = []; + foreach (class_parents($string) + class_implements($string) as $class) { + if (isset($ext->safeClasses[$class])) { + $ext->safeClasses[$c] = array_unique(array_merge($ext->safeClasses[$c], $ext->safeClasses[$class])); + foreach ($ext->safeClasses[$class] as $s) { + $ext->safeLookup[$s][$c] = true; } } } - if (isset($this->safeLookup[$strategy][$c]) || isset($this->safeLookup['all'][$c])) { - return (string) $string; - } } - - $string = (string) $string; - } elseif (\in_array($strategy, ['html', 'js', 'css', 'html_attr', 'url'])) { - return $string; + if (isset($ext->safeLookup[$strategy][$c]) || isset($ext->safeLookup['all'][$c])) { + return (string) $string; + } } - } - if ('' === $string) { - return ''; + $string = (string) $string; + } elseif (\in_array($strategy, ['html', 'js', 'css', 'html_attr', 'url'])) { + return $string; } + } - if (null === $charset) { - $charset = $env->getCharset(); - } + if ('' === $string) { + return ''; + } - switch ($strategy) { - case 'html': - // see https://secure.php.net/htmlspecialchars - - // Using a static variable to avoid initializing the array - // each time the function is called. Moving the declaration on the - // top of the function slow downs other escaping strategies. - static $htmlspecialcharsCharsets = [ - 'ISO-8859-1' => true, 'ISO8859-1' => true, - 'ISO-8859-15' => true, 'ISO8859-15' => true, - 'utf-8' => true, 'UTF-8' => true, - 'CP866' => true, 'IBM866' => true, '866' => true, - 'CP1251' => true, 'WINDOWS-1251' => true, 'WIN-1251' => true, - '1251' => true, - 'CP1252' => true, 'WINDOWS-1252' => true, '1252' => true, - 'KOI8-R' => true, 'KOI8-RU' => true, 'KOI8R' => true, - 'BIG5' => true, '950' => true, - 'GB2312' => true, '936' => true, - 'BIG5-HKSCS' => true, - 'SHIFT_JIS' => true, 'SJIS' => true, '932' => true, - 'EUC-JP' => true, 'EUCJP' => true, - 'ISO8859-5' => true, 'ISO-8859-5' => true, 'MACROMAN' => true, - ]; + if (null === $charset) { + $charset = $env->getCharset(); + } - if (isset($htmlspecialcharsCharsets[$charset])) { - return htmlspecialchars($string, ENT_QUOTES | ENT_SUBSTITUTE, $charset); - } + switch ($strategy) { + case 'html': + // see https://secure.php.net/htmlspecialchars + + // Using a static variable to avoid initializing the array + // each time the function is called. Moving the declaration on the + // top of the function slow downs other escaping strategies. + static $htmlspecialcharsCharsets = [ + 'ISO-8859-1' => true, 'ISO8859-1' => true, + 'ISO-8859-15' => true, 'ISO8859-15' => true, + 'utf-8' => true, 'UTF-8' => true, + 'CP866' => true, 'IBM866' => true, '866' => true, + 'CP1251' => true, 'WINDOWS-1251' => true, 'WIN-1251' => true, + '1251' => true, + 'CP1252' => true, 'WINDOWS-1252' => true, '1252' => true, + 'KOI8-R' => true, 'KOI8-RU' => true, 'KOI8R' => true, + 'BIG5' => true, '950' => true, + 'GB2312' => true, '936' => true, + 'BIG5-HKSCS' => true, + 'SHIFT_JIS' => true, 'SJIS' => true, '932' => true, + 'EUC-JP' => true, 'EUCJP' => true, + 'ISO8859-5' => true, 'ISO-8859-5' => true, 'MACROMAN' => true, + ]; + + if (isset($htmlspecialcharsCharsets[$charset])) { + return htmlspecialchars($string, ENT_QUOTES | ENT_SUBSTITUTE, $charset); + } - if (isset($htmlspecialcharsCharsets[strtoupper($charset)])) { - // cache the lowercase variant for future iterations - $htmlspecialcharsCharsets[$charset] = true; + if (isset($htmlspecialcharsCharsets[strtoupper($charset)])) { + // cache the lowercase variant for future iterations + $htmlspecialcharsCharsets[$charset] = true; - return htmlspecialchars($string, ENT_QUOTES | ENT_SUBSTITUTE, $charset); - } + return htmlspecialchars($string, ENT_QUOTES | ENT_SUBSTITUTE, $charset); + } - $string = iconv($charset, 'UTF-8', $string); - $string = htmlspecialchars($string, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $string = iconv($charset, 'UTF-8', $string); + $string = htmlspecialchars($string, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); - return iconv('UTF-8', $charset, $string); + return iconv('UTF-8', $charset, $string); - case 'js': - // escape all non-alphanumeric characters - // into their \x or \uHHHH representations - if ('UTF-8' !== $charset) { - $string = iconv($charset, 'UTF-8', $string); - } + case 'js': + // escape all non-alphanumeric characters + // into their \x or \uHHHH representations + if ('UTF-8' !== $charset) { + $string = iconv($charset, 'UTF-8', $string); + } - if (!preg_match('//u', $string)) { - throw new RuntimeError('The string to escape is not a valid UTF-8 string.'); - } + if (!preg_match('//u', $string)) { + throw new RuntimeError('The string to escape is not a valid UTF-8 string.'); + } - $string = preg_replace_callback('#[^a-zA-Z0-9,\._]#Su', function ($matches) { - $char = $matches[0]; + $string = preg_replace_callback('#[^a-zA-Z0-9,\._]#Su', function ($matches) { + $char = $matches[0]; + + /* + * A few characters have short escape sequences in JSON and JavaScript. + * Escape sequences supported only by JavaScript, not JSON, are ommitted. + * \" is also supported but omitted, because the resulting string is not HTML safe. + */ + static $shortMap = [ + '\\' => '\\\\', + '/' => '\\/', + "\x08" => '\b', + "\x0C" => '\f', + "\x0A" => '\n', + "\x0D" => '\r', + "\x09" => '\t', + ]; - /* - * A few characters have short escape sequences in JSON and JavaScript. - * Escape sequences supported only by JavaScript, not JSON, are ommitted. - * \" is also supported but omitted, because the resulting string is not HTML safe. - */ - static $shortMap = [ - '\\' => '\\\\', - '/' => '\\/', - "\x08" => '\b', - "\x0C" => '\f', - "\x0A" => '\n', - "\x0D" => '\r', - "\x09" => '\t', - ]; + if (isset($shortMap[$char])) { + return $shortMap[$char]; + } - if (isset($shortMap[$char])) { - return $shortMap[$char]; - } + // \uHHHH + $char = twig_convert_encoding($char, 'UTF-16BE', 'UTF-8'); + $char = strtoupper(bin2hex($char)); - // \uHHHH - $char = twig_convert_encoding($char, 'UTF-16BE', 'UTF-8'); - $char = strtoupper(bin2hex($char)); + if (4 >= \strlen($char)) { + return sprintf('\u%04s', $char); + } - if (4 >= \strlen($char)) { - return sprintf('\u%04s', $char); - } + return sprintf('\u%04s\u%04s', substr($char, 0, -4), substr($char, -4)); + }, $string); - return sprintf('\u%04s\u%04s', substr($char, 0, -4), substr($char, -4)); - }, $string); + if ('UTF-8' !== $charset) { + $string = iconv('UTF-8', $charset, $string); + } - if ('UTF-8' !== $charset) { - $string = iconv('UTF-8', $charset, $string); - } + return $string; - return $string; + case 'css': + if ('UTF-8' !== $charset) { + $string = iconv($charset, 'UTF-8', $string); + } - case 'css': - if ('UTF-8' !== $charset) { - $string = iconv($charset, 'UTF-8', $string); - } + if (!preg_match('//u', $string)) { + throw new RuntimeError('The string to escape is not a valid UTF-8 string.'); + } - if (!preg_match('//u', $string)) { - throw new RuntimeError('The string to escape is not a valid UTF-8 string.'); - } + $string = preg_replace_callback('#[^a-zA-Z0-9]#Su', function ($matches) { + $char = $matches[0]; - $string = preg_replace_callback('#[^a-zA-Z0-9]#Su', function ($matches) { - $char = $matches[0]; + return sprintf('\\%X ', 1 === \strlen($char) ? \ord($char) : mb_ord($char, 'UTF-8')); + }, $string); - return sprintf('\\%X ', 1 === \strlen($char) ? \ord($char) : mb_ord($char, 'UTF-8')); - }, $string); + if ('UTF-8' !== $charset) { + $string = iconv('UTF-8', $charset, $string); + } - if ('UTF-8' !== $charset) { - $string = iconv('UTF-8', $charset, $string); - } + return $string; - return $string; + case 'html_attr': + if ('UTF-8' !== $charset) { + $string = iconv($charset, 'UTF-8', $string); + } - case 'html_attr': - if ('UTF-8' !== $charset) { - $string = iconv($charset, 'UTF-8', $string); - } + if (!preg_match('//u', $string)) { + throw new RuntimeError('The string to escape is not a valid UTF-8 string.'); + } - if (!preg_match('//u', $string)) { - throw new RuntimeError('The string to escape is not a valid UTF-8 string.'); + $string = preg_replace_callback('#[^a-zA-Z0-9,\.\-_]#Su', function ($matches) { + /** + * This function is adapted from code coming from Zend Framework. + * + * @copyright Copyright (c) 2005-2012 Zend Technologies USA Inc. (https://www.zend.com) + * @license https://framework.zend.com/license/new-bsd New BSD License + */ + $chr = $matches[0]; + $ord = \ord($chr); + + /* + * The following replaces characters undefined in HTML with the + * hex entity for the Unicode replacement character. + */ + if (($ord <= 0x1f && "\t" != $chr && "\n" != $chr && "\r" != $chr) || ($ord >= 0x7f && $ord <= 0x9f)) { + return '�'; } - $string = preg_replace_callback('#[^a-zA-Z0-9,\.\-_]#Su', function ($matches) { - /** - * This function is adapted from code coming from Zend Framework. - * - * @copyright Copyright (c) 2005-2012 Zend Technologies USA Inc. (https://www.zend.com) - * @license https://framework.zend.com/license/new-bsd New BSD License - */ - $chr = $matches[0]; - $ord = \ord($chr); - - /* - * The following replaces characters undefined in HTML with the - * hex entity for the Unicode replacement character. - */ - if (($ord <= 0x1f && "\t" != $chr && "\n" != $chr && "\r" != $chr) || ($ord >= 0x7f && $ord <= 0x9f)) { - return '�'; - } - + /* + * Check if the current character to escape has a name entity we should + * replace it with while grabbing the hex value of the character. + */ + if (1 === \strlen($chr)) { /* - * Check if the current character to escape has a name entity we should - * replace it with while grabbing the hex value of the character. - */ - if (1 === \strlen($chr)) { - /* - * While HTML supports far more named entities, the lowest common denominator - * has become HTML5's XML Serialisation which is restricted to the those named - * entities that XML supports. Using HTML entities would result in this error: - * XML Parsing Error: undefined entity - */ - static $entityMap = [ - 34 => '"', /* quotation mark */ - 38 => '&', /* ampersand */ - 60 => '<', /* less-than sign */ - 62 => '>', /* greater-than sign */ - ]; - - if (isset($entityMap[$ord])) { - return $entityMap[$ord]; - } + * While HTML supports far more named entities, the lowest common denominator + * has become HTML5's XML Serialisation which is restricted to the those named + * entities that XML supports. Using HTML entities would result in this error: + * XML Parsing Error: undefined entity + */ + static $entityMap = [ + 34 => '"', /* quotation mark */ + 38 => '&', /* ampersand */ + 60 => '<', /* less-than sign */ + 62 => '>', /* greater-than sign */ + ]; - return sprintf('&#x%02X;', $ord); + if (isset($entityMap[$ord])) { + return $entityMap[$ord]; } - /* - * Per OWASP recommendations, we'll use hex entities for any other - * characters where a named entity does not exist. - */ - return sprintf('&#x%04X;', mb_ord($chr, 'UTF-8')); - }, $string); - - if ('UTF-8' !== $charset) { - $string = iconv('UTF-8', $charset, $string); + return sprintf('&#x%02X;', $ord); } - return $string; - - case 'url': - return rawurlencode($string); - - default: - static $escapers; + /* + * Per OWASP recommendations, we'll use hex entities for any other + * characters where a named entity does not exist. + */ + return sprintf('&#x%04X;', mb_ord($chr, 'UTF-8')); + }, $string); - if (null === $escapers) { - // merge the ones set on CoreExtension for BC (to be removed in 3.0) - $escapers = array_merge( - $env->getExtension(CoreExtension::class)->getEscapers(false), - $env->getExtension(EscaperExtension::class)->getEscapers() - ); - } + if ('UTF-8' !== $charset) { + $string = iconv('UTF-8', $charset, $string); + } - if (isset($escapers[$strategy])) { - return $escapers[$strategy]($env, $string, $charset); - } + return $string; - $validStrategies = implode(', ', array_merge(['html', 'js', 'url', 'css', 'html_attr'], array_keys($escapers))); + case 'url': + return rawurlencode($string); - throw new RuntimeError(sprintf('Invalid escaping strategy "%s" (valid ones: %s).', $strategy, $validStrategies)); - } - } -} + default: + static $escapers; -class_alias('Twig\Extension\EscaperExtension', 'Twig_Extension_Escaper'); -} - -namespace { -use Twig\Environment; -use Twig\Extension\EscaperExtension; -use Twig\Node\Expression\ConstantExpression; -use Twig\Node\Node; + if (null === $escapers) { + // merge the ones set on CoreExtension for BC (to be removed in 3.0) + $escapers = array_merge( + $env->getExtension(CoreExtension::class)->getEscapers(false), + $env->getExtension(EscaperExtension::class)->getEscapers() + ); + } -/** - * Marks a variable as being safe. - * - * @param string $string A PHP variable - * - * @return string - */ -function twig_raw_filter($string) -{ - return $string; -} + if (isset($escapers[$strategy])) { + return $escapers[$strategy]($env, $string, $charset); + } -/** - * @deprecated since Twig 2.11, to be removed in 3.0; use EscaperExtension::escape instead - */ -function twig_escape_filter(Environment $env, $string, $strategy = 'html', $charset = null, $autoescape = false) -{ - @trigger_error(sprintf('The "%s" method is deprecated since Twig 2.11; use "%s::escape" instead.', __METHOD__, EscaperExtension::class), E_USER_DEPRECATED); + $validStrategies = implode(', ', array_merge(['html', 'js', 'url', 'css', 'html_attr'], array_keys($escapers))); - return $env->getExtension(EscaperExtension::class)->escape($env, $string, $strategy, $charset, $autoescape); + throw new RuntimeError(sprintf('Invalid escaping strategy "%s" (valid ones: %s).', $strategy, $validStrategies)); + } } /** diff --git a/test/Twig/Tests/Extension/EscaperTest.php b/test/Twig/Tests/Extension/EscaperTest.php index 88143d9be9..98f5a07842 100644 --- a/test/Twig/Tests/Extension/EscaperTest.php +++ b/test/Twig/Tests/Extension/EscaperTest.php @@ -158,7 +158,7 @@ public function testHtmlEscapingConvertsSpecialChars() { $twig = new \Twig\Environment($this->getMockBuilder(\Twig\Loader\LoaderInterface::class)->getMock()); foreach ($this->htmlSpecialChars as $key => $value) { - $this->assertEquals($value, $twig->getExtension(EscaperExtension::class)->escape($twig, $key, 'html'), 'Failed to escape: '.$key); + $this->assertEquals($value, twig_escape_filter($twig, $key, 'html'), 'Failed to escape: '.$key); } } @@ -166,7 +166,7 @@ public function testHtmlAttributeEscapingConvertsSpecialChars() { $twig = new \Twig\Environment($this->getMockBuilder(\Twig\Loader\LoaderInterface::class)->getMock()); foreach ($this->htmlAttrSpecialChars as $key => $value) { - $this->assertEquals($value, $twig->getExtension(EscaperExtension::class)->escape($twig, $key, 'html_attr'), 'Failed to escape: '.$key); + $this->assertEquals($value, twig_escape_filter($twig, $key, 'html_attr'), 'Failed to escape: '.$key); } } @@ -174,47 +174,47 @@ public function testJavascriptEscapingConvertsSpecialChars() { $twig = new \Twig\Environment($this->getMockBuilder(\Twig\Loader\LoaderInterface::class)->getMock()); foreach ($this->jsSpecialChars as $key => $value) { - $this->assertEquals($value, $twig->getExtension(EscaperExtension::class)->escape($twig, $key, 'js'), 'Failed to escape: '.$key); + $this->assertEquals($value, twig_escape_filter($twig, $key, 'js'), 'Failed to escape: '.$key); } } public function testJavascriptEscapingReturnsStringIfZeroLength() { $twig = new \Twig\Environment($this->getMockBuilder(\Twig\Loader\LoaderInterface::class)->getMock()); - $this->assertEquals('', $twig->getExtension(EscaperExtension::class)->escape($twig, '', 'js')); + $this->assertEquals('', twig_escape_filter($twig, '', 'js')); } public function testJavascriptEscapingReturnsStringIfContainsOnlyDigits() { $twig = new \Twig\Environment($this->getMockBuilder(\Twig\Loader\LoaderInterface::class)->getMock()); - $this->assertEquals('123', $twig->getExtension(EscaperExtension::class)->escape($twig, '123', 'js')); + $this->assertEquals('123', twig_escape_filter($twig, '123', 'js')); } public function testCssEscapingConvertsSpecialChars() { $twig = new \Twig\Environment($this->getMockBuilder(\Twig\Loader\LoaderInterface::class)->getMock()); foreach ($this->cssSpecialChars as $key => $value) { - $this->assertEquals($value, $twig->getExtension(EscaperExtension::class)->escape($twig, $key, 'css'), 'Failed to escape: '.$key); + $this->assertEquals($value, twig_escape_filter($twig, $key, 'css'), 'Failed to escape: '.$key); } } public function testCssEscapingReturnsStringIfZeroLength() { $twig = new \Twig\Environment($this->getMockBuilder(\Twig\Loader\LoaderInterface::class)->getMock()); - $this->assertEquals('', $twig->getExtension(EscaperExtension::class)->escape($twig, '', 'css')); + $this->assertEquals('', twig_escape_filter($twig, '', 'css')); } public function testCssEscapingReturnsStringIfContainsOnlyDigits() { $twig = new \Twig\Environment($this->getMockBuilder(\Twig\Loader\LoaderInterface::class)->getMock()); - $this->assertEquals('123', $twig->getExtension(EscaperExtension::class)->escape($twig, '123', 'css')); + $this->assertEquals('123', twig_escape_filter($twig, '123', 'css')); } public function testUrlEscapingConvertsSpecialChars() { $twig = new \Twig\Environment($this->getMockBuilder(\Twig\Loader\LoaderInterface::class)->getMock()); foreach ($this->urlSpecialChars as $key => $value) { - $this->assertEquals($value, $twig->getExtension(EscaperExtension::class)->escape($twig, $key, 'url'), 'Failed to escape: '.$key); + $this->assertEquals($value, twig_escape_filter($twig, $key, 'url'), 'Failed to escape: '.$key); } } @@ -276,15 +276,15 @@ public function testJavascriptEscapingEscapesOwaspRecommendedRanges() || $chr >= 0x41 && $chr <= 0x5A || $chr >= 0x61 && $chr <= 0x7A) { $literal = $this->codepointToUtf8($chr); - $this->assertEquals($literal, $twig->getExtension(EscaperExtension::class)->escape($twig, $literal, 'js')); + $this->assertEquals($literal, twig_escape_filter($twig, $literal, 'js')); } else { $literal = $this->codepointToUtf8($chr); if (\in_array($literal, $immune)) { - $this->assertEquals($literal, $twig->getExtension(EscaperExtension::class)->escape($twig, $literal, 'js')); + $this->assertEquals($literal, twig_escape_filter($twig, $literal, 'js')); } else { $this->assertNotEquals( $literal, - $twig->getExtension(EscaperExtension::class)->escape($twig, $literal, 'js'), + twig_escape_filter($twig, $literal, 'js'), "$literal should be escaped!"); } } @@ -300,15 +300,15 @@ public function testHtmlAttributeEscapingEscapesOwaspRecommendedRanges() || $chr >= 0x41 && $chr <= 0x5A || $chr >= 0x61 && $chr <= 0x7A) { $literal = $this->codepointToUtf8($chr); - $this->assertEquals($literal, $twig->getExtension(EscaperExtension::class)->escape($twig, $literal, 'html_attr')); + $this->assertEquals($literal, twig_escape_filter($twig, $literal, 'html_attr')); } else { $literal = $this->codepointToUtf8($chr); if (\in_array($literal, $immune)) { - $this->assertEquals($literal, $twig->getExtension(EscaperExtension::class)->escape($twig, $literal, 'html_attr')); + $this->assertEquals($literal, twig_escape_filter($twig, $literal, 'html_attr')); } else { $this->assertNotEquals( $literal, - $twig->getExtension(EscaperExtension::class)->escape($twig, $literal, 'html_attr'), + twig_escape_filter($twig, $literal, 'html_attr'), "$literal should be escaped!"); } } @@ -324,12 +324,12 @@ public function testCssEscapingEscapesOwaspRecommendedRanges() || $chr >= 0x41 && $chr <= 0x5A || $chr >= 0x61 && $chr <= 0x7A) { $literal = $this->codepointToUtf8($chr); - $this->assertEquals($literal, $twig->getExtension(EscaperExtension::class)->escape($twig, $literal, 'css')); + $this->assertEquals($literal, twig_escape_filter($twig, $literal, 'css')); } else { $literal = $this->codepointToUtf8($chr); $this->assertNotEquals( $literal, - $twig->getExtension(EscaperExtension::class)->escape($twig, $literal, 'css'), + twig_escape_filter($twig, $literal, 'css'), "$literal should be escaped!"); } } @@ -343,7 +343,7 @@ public function testCustomEscaper($expected, $string, $strategy) $twig = new Environment($this->getMockBuilder(LoaderInterface::class)->getMock()); $twig->getExtension(EscaperExtension::class)->setEscaper('foo', 'foo_escaper_for_test'); - $this->assertSame($expected, $twig->getExtension(EscaperExtension::class)->escape($twig, $string, $strategy)); + $this->assertSame($expected, twig_escape_filter($twig, $string, $strategy)); } public function provideCustomEscaperCases() @@ -360,8 +360,7 @@ public function provideCustomEscaperCases() */ public function testUnknownCustomEscaper() { - $twig = new Environment($this->getMockBuilder(LoaderInterface::class)->getMock()); - $twig->getExtension(EscaperExtension::class)->escape($twig, 'foo', 'bar'); + twig_escape_filter(new Environment($this->getMockBuilder(LoaderInterface::class)->getMock()), 'foo', 'bar'); } /** @@ -372,9 +371,10 @@ public function testObjectEscaping(string $escapedHtml, string $escapedJs, array $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->getExtension(EscaperExtension::class)->escape($twig, $obj, 'html', null, true)); - $this->assertSame($escapedJs, $twig->getExtension(EscaperExtension::class)->escape($twig, $obj, 'js', null, true)); + $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 [ diff --git a/test/Twig/Tests/IntegrationTest.php b/test/Twig/Tests/IntegrationTest.php index 90a7099a77..6bd727b1ed 100644 --- a/test/Twig/Tests/IntegrationTest.php +++ b/test/Twig/Tests/IntegrationTest.php @@ -22,7 +22,6 @@ use Twig\TwigFilter; use Twig\TwigFunction; use Twig\TwigTest; -use Twig\Extension\EscaperExtension; // This function is defined to check that escaping strategies // like html works even if a function with the same name is defined. @@ -204,7 +203,7 @@ public function §Function($value) */ public function escape_and_nl2br($env, $value, $sep = '
') { - return $this->nl2br($env->getExtension(EscaperExtension::class)->escape($env, $value, 'html'), $sep); + return $this->nl2br(twig_escape_filter($env, $value, 'html'), $sep); } /**