Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add the possibility to register classes/interface as being safe #3025

Merged
merged 2 commits into from May 25, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions 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
Expand Down
18 changes: 18 additions & 0 deletions doc/api.rst
Expand Up @@ -415,6 +415,24 @@ The escaping rules are implemented as follows:
{% set text = "Twig<br />" %}
{{ foo ? text|escape : "<br />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
Expand Down
47 changes: 47 additions & 0 deletions src/Extension/EscaperExtension.php
Expand Up @@ -20,6 +20,12 @@ final class EscaperExtension extends AbstractExtension
private $defaultStrategy;
private $escapers = [];

/** @internal */
public $safeClasses = [];

/** @internal */
public $safeLookup = [];

/**
* @param string|false|callable $defaultStrategy An escaping strategy
*
Expand Down Expand Up @@ -104,6 +110,28 @@ public function getEscapers()
{
return $this->escapers;
}

public function setSafeClasses(array $safeClasses = [])
{
$this->safeClasses = [];
$this->safeLookup = [];
foreach ($safeClasses as $class => $strategies) {
$this->addSafeClass($class, $strategies);
}
}

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);

foreach ($strategies as $strategy) {
$this->safeLookup[$strategy][$class] = true;
}
}
}

class_alias('Twig\Extension\EscaperExtension', 'Twig_Extension_Escaper');
Expand Down Expand Up @@ -148,6 +176,25 @@ function twig_escape_filter(Environment $env, $string, $strategy = 'html', $char

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($ext->safeLookup[$strategy][$c]) || isset($ext->safeLookup['all'][$c])) {
return (string) $string;
}
}

$string = (string) $string;
} elseif (\in_array($strategy, ['html', 'js', 'css', 'html_attr', 'url'])) {
return $string;
Expand Down
33 changes: 33 additions & 0 deletions test/Twig/Tests/Extension/EscaperTest.php
Expand Up @@ -362,9 +362,42 @@ public function testUnknownCustomEscaper()
{
twig_escape_filter(new Environment($this->getMockBuilder(LoaderInterface::class)->getMock()), '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_escape_filter($twig, $obj, 'html', null, true));
$this->assertSame($escapedJs, twig_escape_filter($twig, $obj, 'js', null, true));
}

public function provideObjectsForEscaping()
{
return [
['&lt;br /&gt;', '<br />', ['\Twig_Tests_Extension_TestClass' => ['js']]],
['<br />', '\u003Cbr\u0020\/\u003E', ['\Twig_Tests_Extension_TestClass' => ['html']]],
['&lt;br /&gt;', '<br />', ['\Twig_Tests_Extension_SafeHtmlInterface' => ['js']]],
['<br />', '<br />', ['\Twig_Tests_Extension_SafeHtmlInterface' => ['all']]],
];
}
}

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 '<br />';
}
}