PageRenderTime 22ms CodeModel.GetById 16ms RepoModel.GetById 0ms app.codeStats 0ms

/dev/tests/static/framework/Magento/TestFramework/Utility/XssOutputValidator.php

https://bitbucket.org/leminhtamboy/wisi
PHP | 443 lines | 279 code | 47 blank | 117 comment | 26 complexity | 158df923b4e490d9057b66f488f68726 MD5 | raw file
Possible License(s): BSD-3-Clause, Apache-2.0
  1. <?php
  2. /**
  3. * Copyright © 2013-2017 Magento, Inc. All rights reserved.
  4. * See COPYING.txt for license details.
  5. */
  6. namespace Magento\TestFramework\Utility;
  7. /**
  8. * A helper find not escaped output in phtml templates
  9. */
  10. class XssOutputValidator
  11. {
  12. const ESCAPE_NOT_VERIFIED_PATTERN = '/\* @escapeNotVerified \*/';
  13. const ESCAPED_PATTERN = '/\* @noEscape \*/';
  14. /**
  15. * Store origin for replacements.
  16. *
  17. * @var array
  18. */
  19. private $origins = [];
  20. /**
  21. * Store replacements.
  22. *
  23. * @var array
  24. */
  25. private $replacements = [];
  26. /**
  27. * Array of escape functions.
  28. *
  29. * @var string[]
  30. */
  31. private $escapeFunctions = ['escapeHtml', 'escapeHtmlAttr', 'escapeUrl', 'escapeJs', 'escapeCss'];
  32. /**
  33. *
  34. * @param string $file
  35. * @return string
  36. */
  37. public function getLinesWithXssSensitiveOutput($file)
  38. {
  39. $fileContent = file_get_contents($file);
  40. $xssUnsafeBlocks = $this->getXssUnsafeBlocks($fileContent);
  41. $lines = [];
  42. foreach ($xssUnsafeBlocks as $block) {
  43. $lines = array_merge($lines, $this->findBlockLineNumbers($block, $fileContent));
  44. }
  45. if (count($lines)) {
  46. $lines = array_unique($lines);
  47. sort($lines);
  48. return implode(',', $lines);
  49. }
  50. return '';
  51. }
  52. /**
  53. * Find block line numbers
  54. *
  55. * @param string $block
  56. * @param string $content
  57. * @return array
  58. */
  59. private function findBlockLineNumbers($block, $content)
  60. {
  61. $results = [];
  62. $pos = strpos($content, $block, 0);
  63. while ($pos !== false) {
  64. $contentBeforeString = substr($content, 0, $pos);
  65. if ($this->isNotEscapeMarkedBlock($contentBeforeString)
  66. && $this->isNotInCommentBlock($contentBeforeString)
  67. ) {
  68. $results[] = count(explode(PHP_EOL, $contentBeforeString));
  69. }
  70. $pos = strpos($content, $block, $pos + 1);
  71. }
  72. return $results;
  73. }
  74. /**
  75. * Get XSS unsafe output blocks
  76. *
  77. * @param string $fileContent
  78. * @return array
  79. */
  80. public function getXssUnsafeBlocks($fileContent)
  81. {
  82. $results = [];
  83. $fileContent = $this->replacePhpQuoteWithPlaceholders($fileContent);
  84. $fileContent = $this->replacePhpCommentsWithPlaceholders($fileContent);
  85. $this->addOriginReplacement('\'\'', "'-*=single=*-'");
  86. $this->addOriginReplacement('""', '"-*=double=*-"');
  87. if (preg_match_all('/<[?](php|=)(.*?)[?]>/sm', $fileContent, $phpBlockMatches)) {
  88. foreach ($phpBlockMatches[2] as $index => $phpBlock) {
  89. $phpCommands = explode(';', $phpBlock);
  90. if ($phpBlockMatches[1][$index] == 'php') {
  91. $echoCommands = preg_grep('#( |^|/\*.*?\*/)echo[\s(]+.*#sm', $phpCommands);
  92. } else {
  93. $echoCommands[] = $phpBlockMatches[0][$index];
  94. }
  95. $results = array_merge(
  96. $results,
  97. $this->getEchoUnsafeCommands($echoCommands)
  98. );
  99. }
  100. }
  101. $this->clearOriginReplacements();
  102. $results = array_unique($results);
  103. return $results;
  104. }
  105. /**
  106. * @param array $echoCommands
  107. * @return array
  108. */
  109. private function getEchoUnsafeCommands(array $echoCommands)
  110. {
  111. $results = [];
  112. foreach ($echoCommands as $echoCommand) {
  113. if ($this->isNotEscapeMarkedCommand($echoCommand)) {
  114. $echoCommand = preg_replace('/^(.*?)echo/sim', 'echo', $echoCommand);
  115. $preparedEchoCommand = $this->prepareEchoCommand($echoCommand);
  116. $isEscapeFunctionArgument = preg_match(
  117. '/->(' . implode('|', $this->escapeFunctions) . ')\(.*?\)$/sim',
  118. $preparedEchoCommand
  119. );
  120. $xssUnsafeCommands = array_filter(
  121. $isEscapeFunctionArgument ? [$preparedEchoCommand] : explode('.', $preparedEchoCommand),
  122. [$this, 'isXssUnsafeCommand']
  123. );
  124. if (count($xssUnsafeCommands)) {
  125. $results[] = str_replace(
  126. $this->getReplacements(),
  127. $this->getOrigins(),
  128. $echoCommand
  129. );
  130. }
  131. }
  132. }
  133. return $results;
  134. }
  135. /**
  136. * @param string $command
  137. * @return string
  138. */
  139. private function prepareEchoCommand($command)
  140. {
  141. $command = preg_replace('/<[?]=(.*?)[?]>/sim', '\1', $command);
  142. return trim(ltrim(explode(';', $command)[0], 'echo'));
  143. }
  144. /**
  145. * @param string $contentBeforeString
  146. * @return bool
  147. */
  148. private function isNotEscapeMarkedBlock($contentBeforeString)
  149. {
  150. return !preg_match(
  151. '%(' . self::ESCAPE_NOT_VERIFIED_PATTERN . '|' . self::ESCAPED_PATTERN . ')$%sim',
  152. trim($contentBeforeString)
  153. );
  154. }
  155. /**
  156. * @param string $contentBeforeString
  157. * @return bool
  158. */
  159. private function isNotInCommentBlock($contentBeforeString)
  160. {
  161. $contentBeforeString = explode('<?php', $contentBeforeString);
  162. $contentBeforeString = preg_replace(
  163. '%/\*.*?\*/%si',
  164. '',
  165. end($contentBeforeString)
  166. );
  167. return (strpos($contentBeforeString, '/*') === false);
  168. }
  169. /**
  170. * @param string $command
  171. * @return bool
  172. */
  173. private function isNotEscapeMarkedCommand($command)
  174. {
  175. return !preg_match(
  176. '%' . self::ESCAPE_NOT_VERIFIED_PATTERN . '|' . self::ESCAPED_PATTERN . '%sim',
  177. $command
  178. );
  179. }
  180. /**
  181. * Check if command is xss unsafe
  182. *
  183. * @param string $command
  184. * @return bool
  185. */
  186. public function isXssUnsafeCommand($command)
  187. {
  188. $command = trim($command);
  189. switch (true) {
  190. case preg_match(
  191. '/->(' . implode('|', $this->escapeFunctions) . '|.*html.*)\(/simU',
  192. $this->getLastMethod($command)
  193. ):
  194. return false;
  195. case preg_match('/^\((int|bool|float)\)/sim', $command):
  196. return false;
  197. case preg_match('/^count\(/sim', $command):
  198. return false;
  199. case preg_match("/^'.*'$/sim", $command):
  200. return false;
  201. case preg_match('/^".*?"$/sim', $command, $matches):
  202. return $this->isContainPhpVariables($this->getOrigin($matches[0]));
  203. default:
  204. return true;
  205. }
  206. }
  207. /**
  208. * @param string $command
  209. * @return string
  210. */
  211. private function getLastMethod($command)
  212. {
  213. if (preg_match_all(
  214. '/->.*?\(.*?\)/sim',
  215. $this->clearMethodBracketContent($command),
  216. $matches
  217. )) {
  218. $command = end($matches[0]);
  219. $command = substr($command, 0, strpos($command, '(') + 1);
  220. }
  221. return $command;
  222. }
  223. /**
  224. * @param string $command
  225. * @return string
  226. */
  227. private function clearMethodBracketContent($command)
  228. {
  229. $bracketInterval = [];
  230. $bracketOpenPos = [];
  231. $command = str_split($command);
  232. foreach ($command as $index => $character) {
  233. if ($character == '(') {
  234. array_push($bracketOpenPos, $index);
  235. }
  236. if (count($bracketOpenPos)) {
  237. if ($character == ')') {
  238. $lastOpenPos = array_pop($bracketOpenPos);
  239. if (count($bracketOpenPos) == 0) {
  240. $bracketInterval[] = [$lastOpenPos, $index];
  241. }
  242. }
  243. }
  244. }
  245. foreach ($bracketInterval as $interval) {
  246. for ($i = $interval[0] + 1; $i < $interval[1]; $i++) {
  247. unset($command[$i]);
  248. }
  249. }
  250. $command = implode('', $command);
  251. return $command;
  252. }
  253. /**
  254. * @param string $content
  255. * @return int
  256. */
  257. private function isContainPhpVariables($content)
  258. {
  259. return preg_match('/[^\\\\]\$[a-z_\x7f-\xff]/sim', $content);
  260. }
  261. /**
  262. * @param string $fileContent
  263. * @return string
  264. */
  265. private function replacePhpQuoteWithPlaceholders($fileContent)
  266. {
  267. $origins = [];
  268. $replacements = [];
  269. if (preg_match_all('/<[?](php|=)(.*?)[?]>/sm', $fileContent, $phpBlockMatches)) {
  270. foreach ($phpBlockMatches[2] as $phpBlock) {
  271. $phpBlockQuoteReplaced = preg_replace(
  272. ['/([^\\\\])\'\'/si', '/([^\\\\])""/si'],
  273. ["\1'-*=single=*-'", '\1"-*=double=*-"'],
  274. $phpBlock
  275. );
  276. $this->addQuoteOriginsReplacements(
  277. $phpBlockQuoteReplaced,
  278. [
  279. '/([^\\\\])([\'])(.*?)([^\\\\])([\'])/sim'
  280. ]
  281. );
  282. $this->addQuoteOriginsReplacements(
  283. $phpBlockQuoteReplaced,
  284. [
  285. '/([^\\\\])(["])(.*?)([^\\\\])(["])/sim',
  286. ]
  287. );
  288. $origins[] = $phpBlock;
  289. $replacements[] = str_replace(
  290. $this->getOrigins(),
  291. $this->getReplacements(),
  292. $phpBlockQuoteReplaced
  293. );
  294. }
  295. }
  296. return str_replace($origins, $replacements, $fileContent);
  297. }
  298. /**
  299. * @param string $fileContent
  300. * @return string
  301. */
  302. private function replacePhpCommentsWithPlaceholders($fileContent)
  303. {
  304. $origins= [];
  305. $replacements = [];
  306. if (preg_match_all('%/\*.*?\*/%simu', $fileContent, $docCommentMatches, PREG_SET_ORDER)) {
  307. foreach ($docCommentMatches as $docCommentMatch) {
  308. if ($this->isNotEscapeMarkedCommand($docCommentMatch[0])
  309. && !$this->issetOrigin($docCommentMatch[0])) {
  310. $origin = $docCommentMatch[0];
  311. $replacement = '-*!' . count($this->getOrigins()) . '!*-';
  312. $origins[] = $origin;
  313. $replacements[] = $replacement;
  314. $this->addOriginReplacement(
  315. $origin,
  316. $replacement
  317. );
  318. }
  319. }
  320. }
  321. return str_replace($origins, $replacements, $fileContent);
  322. }
  323. /**
  324. * Add replacements for expressions in single and double quotes
  325. *
  326. * @param string $phpBlock
  327. * @param array $patterns
  328. * @return void
  329. */
  330. private function addQuoteOriginsReplacements($phpBlock, array $patterns)
  331. {
  332. foreach ($patterns as $pattern) {
  333. if (preg_match_all($pattern, $phpBlock, $quoteMatches, PREG_SET_ORDER)) {
  334. foreach ($quoteMatches as $quoteMatch) {
  335. $origin = $quoteMatch[2] . $quoteMatch[3] . $quoteMatch[4] . $quoteMatch[5];
  336. if (!$this->issetOrigin($origin)) {
  337. $this->addOriginReplacement(
  338. $origin,
  339. $quoteMatch[2] . '-*=' . count($this->getOrigins()) . '=*-' . $quoteMatch[5]
  340. );
  341. }
  342. }
  343. }
  344. }
  345. }
  346. /**
  347. * @param string $origin
  348. * @param string $replacement
  349. * @return void
  350. */
  351. private function addOriginReplacement($origin, $replacement)
  352. {
  353. $this->origins[$replacement] = $origin;
  354. $this->replacements[$replacement] = $replacement;
  355. }
  356. /**
  357. * Clear origins and replacements
  358. *
  359. * @return void
  360. */
  361. private function clearOriginReplacements()
  362. {
  363. $this->origins = [];
  364. $this->replacements = [];
  365. }
  366. /**
  367. * @return array
  368. */
  369. private function getOrigins()
  370. {
  371. return $this->origins;
  372. }
  373. /**
  374. * @param string $key
  375. * @return string|null
  376. */
  377. private function getOrigin($key)
  378. {
  379. return array_key_exists($key, $this->origins) ? $this->origins[$key] : null;
  380. }
  381. /**
  382. * @param string $origin
  383. * @return bool
  384. */
  385. private function issetOrigin($origin)
  386. {
  387. return in_array($origin, $this->origins);
  388. }
  389. /**
  390. * @return array
  391. */
  392. private function getReplacements()
  393. {
  394. return $this->replacements;
  395. }
  396. }