1<?php
2
3/*
4 * This file is part of Twig.
5 *
6 * (c) Fabien Potencier
7 *
8 * For the full copyright and license information, please view the LICENSE
9 * file that was distributed with this source code.
10 */
11
12namespace Twig\Runtime;
13
14use Twig\Error\RuntimeError;
15use Twig\Extension\RuntimeExtensionInterface;
16use Twig\Markup;
17
18final class EscaperRuntime implements RuntimeExtensionInterface
19{
20    /** @var array<string, callable(string, string): string> */
21    private $escapers = [];
22
23    /** @internal */
24    public $safeClasses = [];
25
26    /** @internal */
27    public $safeLookup = [];
28
29    public function __construct(
30        private $charset = 'UTF-8',
31    ) {
32    }
33
34    /**
35     * Defines a new escaper to be used via the escape filter.
36     *
37     * @param string                                            $strategy The strategy name that should be used as a strategy in the escape call
38     * @param callable(string $string, string $charset): string $callable A valid PHP callable
39     *
40     * @return void
41     */
42    public function setEscaper($strategy, callable $callable)
43    {
44        $this->escapers[$strategy] = $callable;
45    }
46
47    /**
48     * Gets all defined escapers.
49     *
50     * @return array<string, callable(string $string, string $charset): string> An array of escapers
51     */
52    public function getEscapers()
53    {
54        return $this->escapers;
55    }
56
57    /**
58     * @param array<class-string<\Stringable>, string[]> $safeClasses
59     *
60     * @return void
61     */
62    public function setSafeClasses(array $safeClasses = [])
63    {
64        $this->safeClasses = [];
65        $this->safeLookup = [];
66        foreach ($safeClasses as $class => $strategies) {
67            $this->addSafeClass($class, $strategies);
68        }
69    }
70
71    /**
72     * @param class-string<\Stringable> $class
73     * @param string[]                  $strategies
74     *
75     * @return void
76     */
77    public function addSafeClass(string $class, array $strategies)
78    {
79        $class = ltrim($class, '\\');
80        if (!isset($this->safeClasses[$class])) {
81            $this->safeClasses[$class] = [];
82        }
83        $this->safeClasses[$class] = array_merge($this->safeClasses[$class], $strategies);
84
85        foreach ($strategies as $strategy) {
86            $this->safeLookup[$strategy][$class] = true;
87        }
88    }
89
90    /**
91     * Escapes a string.
92     *
93     * @param mixed       $string     The value to be escaped
94     * @param string      $strategy   The escaping strategy
95     * @param string|null $charset    The charset
96     * @param bool        $autoescape Whether the function is called by the auto-escaping feature (true) or by the developer (false)
97     *
98     * @throws RuntimeError
99     */
100    public function escape($string, string $strategy = 'html', ?string $charset = null, bool $autoescape = false)
101    {
102        if ($autoescape && $string instanceof Markup) {
103            return $string;
104        }
105
106        if (!\is_string($string)) {
107            if ($string instanceof \Stringable) {
108                if ($autoescape) {
109                    $c = $string::class;
110                    if (!isset($this->safeClasses[$c])) {
111                        $this->safeClasses[$c] = [];
112                        foreach (class_parents($string) + class_implements($string) as $class) {
113                            if (isset($this->safeClasses[$class])) {
114                                $this->safeClasses[$c] = array_unique(array_merge($this->safeClasses[$c], $this->safeClasses[$class]));
115                                foreach ($this->safeClasses[$class] as $s) {
116                                    $this->safeLookup[$s][$c] = true;
117                                }
118                            }
119                        }
120                    }
121                    if (isset($this->safeLookup[$strategy][$c]) || isset($this->safeLookup['all'][$c])) {
122                        return (string) $string;
123                    }
124                }
125
126                $string = (string) $string;
127            } elseif (\in_array($strategy, ['html', 'js', 'css', 'html_attr', 'html_attr_relaxed', 'url'], true)) {
128                // we return the input as is (which can be of any type)
129                return $string;
130            }
131        }
132
133        if ('' === $string) {
134            return '';
135        }
136
137        $charset = $charset ?: $this->charset;
138
139        switch ($strategy) {
140            case 'html':
141                // see https://www.php.net/htmlspecialchars
142
143                if ('UTF-8' === $charset) {
144                    return htmlspecialchars($string, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8');
145                }
146
147                // Using a static variable to avoid initializing the array
148                // each time the function is called. Moving the declaration on the
149                // top of the function slow downs other escaping strategies.
150                static $htmlspecialcharsCharsets = [
151                    'ISO-8859-1' => true, 'ISO8859-1' => true,
152                    'ISO-8859-15' => true, 'ISO8859-15' => true,
153                    'utf-8' => true, 'UTF-8' => true,
154                    'CP866' => true, 'IBM866' => true, '866' => true,
155                    'CP1251' => true, 'WINDOWS-1251' => true, 'WIN-1251' => true,
156                    '1251' => true,
157                    'CP1252' => true, 'WINDOWS-1252' => true, '1252' => true,
158                    'KOI8-R' => true, 'KOI8-RU' => true, 'KOI8R' => true,
159                    'BIG5' => true, '950' => true,
160                    'GB2312' => true, '936' => true,
161                    'BIG5-HKSCS' => true,
162                    'SHIFT_JIS' => true, 'SJIS' => true, '932' => true,
163                    'EUC-JP' => true, 'EUCJP' => true,
164                    'ISO8859-5' => true, 'ISO-8859-5' => true, 'MACROMAN' => true,
165                ];
166
167                if (isset($htmlspecialcharsCharsets[$charset])) {
168                    return htmlspecialchars($string, \ENT_QUOTES | \ENT_SUBSTITUTE, $charset);
169                }
170
171                if (isset($htmlspecialcharsCharsets[strtoupper($charset)])) {
172                    // cache the lowercase variant for future iterations
173                    $htmlspecialcharsCharsets[$charset] = true;
174
175                    return htmlspecialchars($string, \ENT_QUOTES | \ENT_SUBSTITUTE, $charset);
176                }
177
178                $string = $this->convertEncoding($string, 'UTF-8', $charset);
179                $string = htmlspecialchars($string, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8');
180
181                return iconv('UTF-8', $charset, $string);
182
183            case 'js':
184                // escape all non-alphanumeric characters
185                // into their \x or \uHHHH representations
186                if ('UTF-8' !== $charset) {
187                    $string = $this->convertEncoding($string, 'UTF-8', $charset);
188                }
189
190                if (!preg_match('//u', $string)) {
191                    throw new RuntimeError('The string to escape is not a valid UTF-8 string.');
192                }
193
194                $string = preg_replace_callback('#[^a-zA-Z0-9,\._]#Su', static function ($matches) {
195                    $char = $matches[0];
196
197                    /*
198                    * A few characters have short escape sequences in JSON and JavaScript.
199                    * Escape sequences supported only by JavaScript, not JSON, are omitted.
200                    * \" is also supported but omitted, because the resulting string is not HTML safe.
201                    */
202                    $short = match ($char) {
203                        '\\' => '\\\\',
204                        '/' => '\\/',
205                        "\x08" => '\b',
206                        "\x0C" => '\f',
207                        "\x0A" => '\n',
208                        "\x0D" => '\r',
209                        "\x09" => '\t',
210                        default => false,
211                    };
212
213                    if ($short) {
214                        return $short;
215                    }
216
217                    $codepoint = mb_ord($char, 'UTF-8');
218                    if (0x10000 > $codepoint) {
219                        return \sprintf('\u%04X', $codepoint);
220                    }
221
222                    // Split characters outside the BMP into surrogate pairs
223                    // https://tools.ietf.org/html/rfc2781.html#section-2.1
224                    $u = $codepoint - 0x10000;
225                    $high = 0xD800 | ($u >> 10);
226                    $low = 0xDC00 | ($u & 0x3FF);
227
228                    return \sprintf('\u%04X\u%04X', $high, $low);
229                }, $string);
230
231                if ('UTF-8' !== $charset) {
232                    $string = iconv('UTF-8', $charset, $string);
233                }
234
235                return $string;
236
237            case 'css':
238                if ('UTF-8' !== $charset) {
239                    $string = $this->convertEncoding($string, 'UTF-8', $charset);
240                }
241
242                if (!preg_match('//u', $string)) {
243                    throw new RuntimeError('The string to escape is not a valid UTF-8 string.');
244                }
245
246                $string = preg_replace_callback('#[^a-zA-Z0-9]#Su', static function ($matches) {
247                    $char = $matches[0];
248
249                    return \sprintf('\\%X ', 1 === \strlen($char) ? \ord($char) : mb_ord($char, 'UTF-8'));
250                }, $string);
251
252                if ('UTF-8' !== $charset) {
253                    $string = iconv('UTF-8', $charset, $string);
254                }
255
256                return $string;
257
258            case 'html_attr':
259            case 'html_attr_relaxed':
260                if ('UTF-8' !== $charset) {
261                    $string = $this->convertEncoding($string, 'UTF-8', $charset);
262                }
263
264                if (!preg_match('//u', $string)) {
265                    throw new RuntimeError('The string to escape is not a valid UTF-8 string.');
266                }
267
268                $regex = match ($strategy) {
269                    'html_attr' => '#[^a-zA-Z0-9,\.\-_]#Su',
270                    'html_attr_relaxed' => '#[^a-zA-Z0-9,\.\-_:@\[\]]#Su',
271                };
272
273                $string = preg_replace_callback($regex, static function ($matches) {
274                    /**
275                     * This function is adapted from code coming from Zend Framework.
276                     *
277                     * @copyright Copyright (c) 2005-2012 Zend Technologies USA Inc. (https://www.zend.com)
278                     * @license   https://framework.zend.com/license/new-bsd New BSD License
279                     */
280                    $chr = $matches[0];
281                    $ord = \ord($chr[0]);
282
283                    /*
284                    * The following replaces characters undefined in HTML with the
285                    * hex entity for the Unicode replacement character.
286                    */
287                    if (($ord <= 0x1F && "\t" != $chr && "\n" != $chr && "\r" != $chr) || ($ord >= 0x7F && $ord <= 0x9F)) {
288                        return '&#xFFFD;';
289                    }
290
291                    /*
292                    * Check if the current character to escape has a name entity we should
293                    * replace it with while grabbing the hex value of the character.
294                    */
295                    if (1 === \strlen($chr)) {
296                        /*
297                        * While HTML supports far more named entities, the lowest common denominator
298                        * has become HTML5's XML Serialisation which is restricted to the those named
299                        * entities that XML supports. Using HTML entities would result in this error:
300                        *     XML Parsing Error: undefined entity
301                        */
302                        return match ($ord) {
303                            34 => '&quot;', /* quotation mark */
304                            38 => '&amp;',  /* ampersand */
305                            60 => '&lt;',   /* less-than sign */
306                            62 => '&gt;',   /* greater-than sign */
307                            default => \sprintf('&#x%02X;', $ord),
308                        };
309                    }
310
311                    /*
312                    * Per OWASP recommendations, we'll use hex entities for any other
313                    * characters where a named entity does not exist.
314                    */
315                    return \sprintf('&#x%04X;', mb_ord($chr, 'UTF-8'));
316                }, $string);
317
318                if ('UTF-8' !== $charset) {
319                    $string = iconv('UTF-8', $charset, $string);
320                }
321
322                return $string;
323
324            case 'url':
325                return rawurlencode($string);
326
327            default:
328                if (\array_key_exists($strategy, $this->escapers)) {
329                    return $this->escapers[$strategy]($string, $charset);
330                }
331
332                $validStrategies = implode('", "', array_merge(['html', 'js', 'url', 'css', 'html_attr', 'html_attr_relaxed'], array_keys($this->escapers)));
333
334                throw new RuntimeError(\sprintf('Invalid escaping strategy "%s" (valid ones: "%s").', $strategy, $validStrategies));
335        }
336    }
337
338    private function convertEncoding(string $string, string $to, string $from)
339    {
340        if (!\function_exists('iconv')) {
341            throw new RuntimeError('Unable to convert encoding: required function iconv() does not exist. You should install ext-iconv or symfony/polyfill-iconv.');
342        }
343
344        return iconv($from, $to, $string);
345    }
346}
347