Magento 2 Documentation  2.3
Documentation for Magento 2 CMS v2.3 (December 2018)
XssOutputValidator.php
Go to the documentation of this file.
1 <?php
8 
12 class XssOutputValidator
13 {
14  const ESCAPE_NOT_VERIFIED_PATTERN = '/\* @escapeNotVerified \*/';
15 
16  const ESCAPED_PATTERN = '/\* @noEscape \*/';
17 
23  private $origins = [];
24 
30  private $replacements = [];
31 
37  private $escapeFunctions = ['escapeHtml', 'escapeHtmlAttr', 'escapeUrl', 'escapeJs', 'escapeCss'];
38 
44  public function getLinesWithXssSensitiveOutput($file)
45  {
46  $fileContent = file_get_contents($file);
47  $xssUnsafeBlocks = $this->getXssUnsafeBlocks($fileContent);
48 
49  $lines = [];
50  foreach ($xssUnsafeBlocks as $block) {
51  $lines = array_merge($lines, $this->findBlockLineNumbers($block, $fileContent));
52  }
53 
54  if (count($lines)) {
55  $lines = array_unique($lines);
56  sort($lines);
57  return implode(',', $lines);
58  }
59 
60  return '';
61  }
62 
70  private function findBlockLineNumbers($block, $content)
71  {
72  $results = [];
73  $pos = strpos($content, $block, 0);
74  while ($pos !== false) {
75  $contentBeforeString = substr($content, 0, $pos);
76  if ($this->isNotEscapeMarkedBlock($contentBeforeString)
77  && $this->isNotInCommentBlock($contentBeforeString)
78  ) {
79  $results[] = count(explode(PHP_EOL, $contentBeforeString));
80  }
81  $pos = strpos($content, $block, $pos + 1);
82  }
83 
84  return $results;
85  }
86 
93  public function getXssUnsafeBlocks($fileContent)
94  {
95  $results = [];
96 
97  $fileContent = $this->replacePhpQuoteWithPlaceholders($fileContent);
98  $fileContent = $this->replacePhpCommentsWithPlaceholders($fileContent);
99 
100  $this->addOriginReplacement('\'\'', "'-*=single=*-'");
101  $this->addOriginReplacement('""', '"-*=double=*-"');
102 
103  if (preg_match_all('/<[?](php|=)(.*?)[?]>/sm', $fileContent, $phpBlockMatches)) {
104  foreach ($phpBlockMatches[2] as $index => $phpBlock) {
105  $phpCommands = explode(';', $phpBlock);
106  if ($phpBlockMatches[1][$index] == 'php') {
107  $echoCommands = preg_grep('#( |^|/\*.*?\*/)echo[\s(]+.*#sm', $phpCommands);
108  } else {
109  $echoCommands[] = $phpBlockMatches[0][$index];
110  }
111  $results = array_merge(
112  $results,
113  $this->getEchoUnsafeCommands($echoCommands)
114  );
115  }
116  }
117 
118  $this->clearOriginReplacements();
119  $results = array_unique($results);
120 
121  return $results;
122  }
123 
128  private function getEchoUnsafeCommands(array $echoCommands)
129  {
130  $results = [];
131  foreach ($echoCommands as $echoCommand) {
132  if ($this->isNotEscapeMarkedCommand($echoCommand)) {
133  $echoCommand = preg_replace('/^(.*?)echo/sim', 'echo', $echoCommand);
134  $preparedEchoCommand = $this->prepareEchoCommand($echoCommand);
135  $isEscapeFunctionArgument = preg_match(
136  '/->(' . implode('|', $this->escapeFunctions) . ')\(.*?\)$/sim',
137  $preparedEchoCommand
138  );
139  $xssUnsafeCommands = array_filter(
140  $isEscapeFunctionArgument ? [$preparedEchoCommand] : explode('.', $preparedEchoCommand),
141  [$this, 'isXssUnsafeCommand']
142  );
143  if (count($xssUnsafeCommands)) {
144  $results[] = str_replace(
145  $this->getReplacements(),
146  $this->getOrigins(),
147  $echoCommand
148  );
149  }
150  }
151  }
152 
153  return $results;
154  }
155 
160  private function prepareEchoCommand($command)
161  {
162  $command = preg_replace('/<[?]=(.*?)[?]>/sim', '\1', $command);
163  return trim(ltrim(explode(';', $command)[0], 'echo'));
164  }
165 
170  private function isNotEscapeMarkedBlock($contentBeforeString)
171  {
172  return !preg_match(
173  '%(' . self::ESCAPE_NOT_VERIFIED_PATTERN . '|' . self::ESCAPED_PATTERN . ')$%sim',
174  trim($contentBeforeString)
175  );
176  }
177 
182  private function isNotInCommentBlock($contentBeforeString)
183  {
184  $contentBeforeString = explode('<?php', $contentBeforeString);
185  $contentBeforeString = preg_replace(
186  '%/\*.*?\*/%si',
187  '',
188  end($contentBeforeString)
189  );
190 
191  return (strpos($contentBeforeString, '/*') === false);
192  }
193 
198  private function isNotEscapeMarkedCommand($command)
199  {
200  return !preg_match(
201  '%' . self::ESCAPE_NOT_VERIFIED_PATTERN . '|' . self::ESCAPED_PATTERN . '%sim',
202  $command
203  );
204  }
205 
212  public function isXssUnsafeCommand($command)
213  {
214  $command = trim($command);
215 
216  switch (true) {
217  case preg_match(
218  '/->(' . implode('|', $this->escapeFunctions) . '|.*html.*)\(/simU',
219  $this->getLastMethod($command)
220  ):
221  return false;
222  case preg_match('/^\((int|bool|float)\)/sim', $command):
223  return false;
224  case preg_match('/^count\(/sim', $command):
225  return false;
226  case preg_match("/^'.*'$/sim", $command):
227  return false;
228  case preg_match('/^".*?"$/sim', $command, $matches):
229  return $this->isContainPhpVariables($this->getOrigin($matches[0]));
230  default:
231  return true;
232  }
233  }
234 
239  private function getLastMethod($command)
240  {
241  if (preg_match_all(
242  '/->.*?\(.*?\)/sim',
243  $this->clearMethodBracketContent($command),
244  $matches
245  )) {
246  $command = end($matches[0]);
247  $command = substr($command, 0, strpos($command, '(') + 1);
248  }
249 
250  return $command;
251  }
252 
257  private function clearMethodBracketContent($command)
258  {
259  $bracketInterval = [];
260  $bracketOpenPos = [];
261  $command = str_split($command);
262  foreach ($command as $index => $character) {
263  if ($character == '(') {
264  array_push($bracketOpenPos, $index);
265  }
266  if (count($bracketOpenPos)) {
267  if ($character == ')') {
268  $lastOpenPos = array_pop($bracketOpenPos);
269  if (count($bracketOpenPos) == 0) {
270  $bracketInterval[] = [$lastOpenPos, $index];
271  }
272  }
273  }
274  }
275  foreach ($bracketInterval as $interval) {
276  for ($i = $interval[0] + 1; $i < $interval[1]; $i++) {
277  unset($command[$i]);
278  }
279  }
280  $command = implode('', $command);
281 
282  return $command;
283  }
284 
289  private function isContainPhpVariables($content)
290  {
291  return preg_match('/[^\\\\]\$[a-z_\x7f-\xff]/sim', $content);
292  }
293 
298  private function replacePhpQuoteWithPlaceholders($fileContent)
299  {
300  $origins = [];
301  $replacements = [];
302  if (preg_match_all('/<[?](php|=)(.*?)[?]>/sm', $fileContent, $phpBlockMatches)) {
303  foreach ($phpBlockMatches[2] as $phpBlock) {
304  $phpBlockQuoteReplaced = preg_replace(
305  ['/([^\\\\])\'\'/si', '/([^\\\\])""/si'],
306  ["\1'-*=single=*-'", '\1"-*=double=*-"'],
307  $phpBlock
308  );
309 
310  $this->addQuoteOriginsReplacements(
311  $phpBlockQuoteReplaced,
312  [
313  '/([^\\\\])([\'])(.*?)([^\\\\])([\'])/sim'
314  ]
315  );
316  $this->addQuoteOriginsReplacements(
317  $phpBlockQuoteReplaced,
318  [
319  '/([^\\\\])(["])(.*?)([^\\\\])(["])/sim',
320  ]
321  );
322 
323  $origins[] = $phpBlock;
324  $replacements[] = str_replace(
325  $this->getOrigins(),
326  $this->getReplacements(),
327  $phpBlockQuoteReplaced
328  );
329  }
330  }
331 
332  return str_replace($origins, $replacements, $fileContent);
333  }
334 
339  private function replacePhpCommentsWithPlaceholders($fileContent)
340  {
341  $origins= [];
342  $replacements = [];
343  if (preg_match_all('%/\*.*?\*/%simu', $fileContent, $docCommentMatches, PREG_SET_ORDER)) {
344  foreach ($docCommentMatches as $docCommentMatch) {
345  if ($this->isNotEscapeMarkedCommand($docCommentMatch[0])
346  && !$this->issetOrigin($docCommentMatch[0])) {
347  $origin = $docCommentMatch[0];
348  $replacement = '-*!' . count($this->getOrigins()) . '!*-';
349  $origins[] = $origin;
350  $replacements[] = $replacement;
351  $this->addOriginReplacement(
352  $origin,
354  );
355  }
356  }
357  }
358 
359  return str_replace($origins, $replacements, $fileContent);
360  }
361 
369  private function addQuoteOriginsReplacements($phpBlock, array $patterns)
370  {
371  foreach ($patterns as $pattern) {
372  if (preg_match_all($pattern, $phpBlock, $quoteMatches, PREG_SET_ORDER)) {
373  foreach ($quoteMatches as $quoteMatch) {
374  $origin = $quoteMatch[2] . $quoteMatch[3] . $quoteMatch[4] . $quoteMatch[5];
375  if (!$this->issetOrigin($origin)) {
376  $this->addOriginReplacement(
377  $origin,
378  $quoteMatch[2] . '-*=' . count($this->getOrigins()) . '=*-' . $quoteMatch[5]
379  );
380  }
381  }
382  }
383  }
384  }
385 
391  private function addOriginReplacement($origin, $replacement)
392  {
393  $this->origins[$replacement] = $origin;
394  $this->replacements[$replacement] = $replacement;
395  }
396 
402  private function clearOriginReplacements()
403  {
404  $this->origins = [];
405  $this->replacements = [];
406  }
407 
411  private function getOrigins()
412  {
413  return $this->origins;
414  }
415 
420  private function getOrigin($key)
421  {
422  return array_key_exists($key, $this->origins) ? $this->origins[$key] : null;
423  }
424 
429  private function issetOrigin($origin)
430  {
431  return in_array($origin, $this->origins);
432  }
433 
437  private function getReplacements()
438  {
439  return $this->replacements;
440  }
441 }
$results
Definition: popup.phtml:13
return false
Definition: gallery.phtml:36
$pattern
Definition: website.php:22
$block
Definition: block.php:8
$replacement
Definition: website.php:23
$pos
Definition: list.phtml:42
return['app/code */registration.php', 'app/design */registration.php', 'lib/internal **/registration.php']
$i
Definition: gallery.phtml:31
$index
Definition: list.phtml:44