Browse code

feature #3025 Add the possibility to register classes/interface as being safe (fabpot)

This PR was squashed before being merged into the 2.x branch (closes #3025).

Discussion
----------

Add the possibility to register classes/interface as being safe

closes #2548

To avoid a too big performance impact on the escaper, we aggressively cache the safe classes, which means that changing the. configuration at runtime is not possible (and having different ones on 2 Twig instances is not possible either, this is really *globally* configured).

Commits
-------

fe6503fe -
b18733bc added the possibility to register classes/interfaces as being safe for the escaper

Fabien Potencier authored on 25/05/2019 10:23:33
Showing 4 changed files
... ...
@@ -1,5 +1,6 @@
1 1
 * 2.11.0 (2019-XX-XX)
2 2
 
3
+ * added the possibility to register classes/interfaces as being safe for the escaper ("EscaperExtension::addSafeClass()")
3 4
  * deprecated CoreExtension::setEscaper() and CoreExtension::getEscapers() in favor of the same methods on EscaperExtension
4 5
  * macros are now auto-imported in the template they are defined (under the ``_self`` variable)
5 6
  * added support for macros on "is defined" tests
... ...
@@ -415,6 +415,24 @@ The escaping rules are implemented as follows:
415 415
         {% set text = "Twig<br />" %}
416 416
         {{ foo ? text|escape : "<br />Twig" }} {# the result of the expression won't be escaped #}
417 417
 
418
+* Objects with a ``__toString`` method are converted to strings and
419
+  escaped. You can mark some classes and/or interfaces as being safe for some
420
+  strategies via ``EscaperExtension::addSafeClass()``:
421
+
422
+  .. code-block:: twig
423
+
424
+        // mark object of class Foo as safe for the HTML strategy
425
+        $escaper->addSafeClass('Foo', ['html']);
426
+
427
+        // mark object of interface Foo as safe for the HTML strategy
428
+        $escaper->addSafeClass('FooInterface', ['html']);
429
+
430
+        // mark object of class Foo as safe for the HTML and JS strategies
431
+        $escaper->addSafeClass('Foo', ['html', 'js']);
432
+
433
+        // mark object of class Foo as safe for all strategies
434
+        $escaper->addSafeClass('Foo', ['all']);
435
+
418 436
 * Escaping is applied before printing, after any other filter is applied:
419 437
 
420 438
   .. code-block:: twig
... ...
@@ -20,6 +20,12 @@ final class EscaperExtension extends AbstractExtension
20 20
     private $defaultStrategy;
21 21
     private $escapers = [];
22 22
 
23
+    /** @internal */
24
+    public $safeClasses = [];
25
+
26
+    /** @internal */
27
+    public $safeLookup = [];
28
+
23 29
     /**
24 30
      * @param string|false|callable $defaultStrategy An escaping strategy
25 31
      *
... ...
@@ -104,6 +110,28 @@ final class EscaperExtension extends AbstractExtension
104 110
     {
105 111
         return $this->escapers;
106 112
     }
113
+
114
+    public function setSafeClasses(array $safeClasses = [])
115
+    {
116
+        $this->safeClasses = [];
117
+        $this->safeLookup = [];
118
+        foreach ($safeClasses as $class => $strategies) {
119
+            $this->addSafeClass($class, $strategies);
120
+        }
121
+    }
122
+
123
+    public function addSafeClass(string $class, array $strategies)
124
+    {
125
+        $class = ltrim($class, '\\');
126
+        if (!isset($this->safeClasses[$class])) {
127
+            $this->safeClasses[$class] = [];
128
+        }
129
+        $this->safeClasses[$class] = array_merge($this->safeClasses[$class], $strategies);
130
+
131
+        foreach ($strategies as $strategy) {
132
+            $this->safeLookup[$strategy][$class] = true;
133
+        }
134
+    }
107 135
 }
108 136
 
109 137
 class_alias('Twig\Extension\EscaperExtension', 'Twig_Extension_Escaper');
... ...
@@ -148,6 +176,25 @@ function twig_escape_filter(Environment $env, $string, $strategy = 'html', $char
148 176
 
149 177
     if (!\is_string($string)) {
150 178
         if (\is_object($string) && method_exists($string, '__toString')) {
179
+            if ($autoescape) {
180
+                $c = \get_class($string);
181
+                $ext = $env->getExtension(EscaperExtension::class);
182
+                if (!isset($ext->safeClasses[$c])) {
183
+                    $ext->safeClasses[$c] = [];
184
+                    foreach (class_parents($string) + class_implements($string) as $class) {
185
+                        if (isset($ext->safeClasses[$class])) {
186
+                            $ext->safeClasses[$c] = array_unique(array_merge($ext->safeClasses[$c], $ext->safeClasses[$class]));
187
+                            foreach ($ext->safeClasses[$class] as $s) {
188
+                                $ext->safeLookup[$s][$c] = true;
189
+                            }
190
+                        }
191
+                    }
192
+                }
193
+                if (isset($ext->safeLookup[$strategy][$c]) || isset($ext->safeLookup['all'][$c])) {
194
+                    return (string) $string;
195
+                }
196
+            }
197
+
151 198
             $string = (string) $string;
152 199
         } elseif (\in_array($strategy, ['html', 'js', 'css', 'html_attr', 'url'])) {
153 200
             return $string;
... ...
@@ -362,9 +362,42 @@ class Twig_Tests_Extension_EscaperTest extends \PHPUnit\Framework\TestCase
362 362
     {
363 363
         twig_escape_filter(new Environment($this->getMockBuilder(LoaderInterface::class)->getMock()), 'foo', 'bar');
364 364
     }
365
+
366
+    /**
367
+     * @dataProvider provideObjectsForEscaping
368
+     */
369
+    public function testObjectEscaping(string $escapedHtml, string $escapedJs, array $safeClasses)
370
+    {
371
+        $obj = new Twig_Tests_Extension_TestClass();
372
+        $twig = new Environment($this->getMockBuilder('\Twig\Loader\LoaderInterface')->getMock());
373
+        $twig->getExtension('\Twig\Extension\EscaperExtension')->setSafeClasses($safeClasses);
374
+        $this->assertSame($escapedHtml, twig_escape_filter($twig, $obj, 'html', null, true));
375
+        $this->assertSame($escapedJs, twig_escape_filter($twig, $obj, 'js', null, true));
376
+    }
377
+
378
+    public function provideObjectsForEscaping()
379
+    {
380
+        return [
381
+            ['&lt;br /&gt;', '<br />', ['\Twig_Tests_Extension_TestClass' => ['js']]],
382
+            ['<br />', '\u003Cbr\u0020\/\u003E', ['\Twig_Tests_Extension_TestClass' => ['html']]],
383
+            ['&lt;br /&gt;', '<br />', ['\Twig_Tests_Extension_SafeHtmlInterface' => ['js']]],
384
+            ['<br />', '<br />', ['\Twig_Tests_Extension_SafeHtmlInterface' => ['all']]],
385
+        ];
386
+    }
365 387
 }
366 388
 
367 389
 function foo_escaper_for_test(Environment $twig, $string, $charset)
368 390
 {
369 391
     return $string.$charset;
370 392
 }
393
+
394
+interface Twig_Tests_Extension_SafeHtmlInterface
395
+{
396
+}
397
+class Twig_Tests_Extension_TestClass implements Twig_Tests_Extension_SafeHtmlInterface
398
+{
399
+    public function __toString()
400
+    {
401
+        return '<br />';
402
+    }
403
+}