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 '�'; 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 => '"', /* quotation mark */ 304 38 => '&', /* ampersand */ 305 60 => '<', /* less-than sign */ 306 62 => '>', /* 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