1
0

TwigAutoescapeTest.php 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Tests\Http;
  4. use App\Http\View;
  5. use App\Tests\TestCase;
  6. use FilesystemIterator;
  7. use RecursiveDirectoryIterator;
  8. use RecursiveIteratorIterator;
  9. /**
  10. * R01-N21: Twig auto-escape is the only barrier between user-supplied
  11. * strings (sprint name, task title, audit JSON, …) and stored XSS in
  12. * the views that render them. One careless `|raw` or `{% autoescape
  13. * false %}` opens the door.
  14. *
  15. * Two complementary checks:
  16. *
  17. * - **Behaviour pin**: render a known XSS payload through a synthetic
  18. * Twig template using the same `View` env the controllers use, and
  19. * assert the output is HTML-escaped. This catches a future change
  20. * to `View`'s `autoescape` config (e.g. someone flips it to `'name'`
  21. * so a template-name-derived strategy kicks in).
  22. *
  23. * - **Static guard**: scan every `views` `.twig` template for the
  24. * escape-bypass forms — `|raw`, `|safe`, `{% autoescape false %}`,
  25. * and any `{% autoescape ... %}` override — and fail if any are
  26. * present. Tests like this beat code review when the codebase
  27. * grows.
  28. */
  29. final class TwigAutoescapeTest extends TestCase
  30. {
  31. public function testHtmlAutoescapeEscapesXssPayloadEndToEnd(): void
  32. {
  33. $view = new View(__DIR__ . '/../../views');
  34. $twig = $view->twig();
  35. $payload = '<script>alert(1)</script>';
  36. $tpl = $twig->createTemplate('{{ x }}');
  37. $out = $tpl->render(['x' => $payload]);
  38. // The literal `<script>` opener must NOT survive — a real
  39. // autoescape=html env will produce `&lt;script&gt;…`. We
  40. // assert both the negative (no raw tag) and the positive
  41. // (the entity-encoded form is present) so a flip to
  42. // `autoescape: false` would fail loudly.
  43. self::assertStringNotContainsString(
  44. '<script>',
  45. $out,
  46. 'autoescape=html must HTML-escape user values rendered with {{ }}',
  47. );
  48. self::assertStringContainsString('&lt;script&gt;', $out);
  49. self::assertStringContainsString('&lt;/script&gt;', $out);
  50. }
  51. public function testAttributeContextEscapesQuoteCharacters(): void
  52. {
  53. // The `e('html_attr')` filter is the documented attribute-context
  54. // recipe in this codebase. Auto-escape inside `"…"` attribute
  55. // values defaults to HTML escape, which is enough for the
  56. // double-quote → `&quot;` replacement that closes the attribute
  57. // injection vector.
  58. $view = new View(__DIR__ . '/../../views');
  59. $twig = $view->twig();
  60. $tpl = $twig->createTemplate('<div title="{{ x }}"></div>');
  61. $out = $tpl->render(['x' => '" onmouseover="alert(1)']);
  62. self::assertStringNotContainsString('onmouseover="alert(1)', $out);
  63. self::assertStringContainsString('&quot;', $out);
  64. }
  65. /**
  66. * Scan every `.twig` file under `views` for escape-bypass forms.
  67. * A future template with `|raw` (or `{% autoescape false %}`)
  68. * would make stored XSS one careless commit away — this test
  69. * fails fast with the offending lines so the reviewer sees
  70. * them in CI.
  71. */
  72. public function testNoRawSafeOrAutoescapeOverrideInViews(): void
  73. {
  74. $viewsDir = realpath(__DIR__ . '/../../views');
  75. self::assertNotFalse($viewsDir, 'views/ must exist');
  76. $iter = new RecursiveIteratorIterator(
  77. new RecursiveDirectoryIterator($viewsDir, FilesystemIterator::SKIP_DOTS)
  78. );
  79. // Two independent regexes — running both on the same line keeps
  80. // the failure message precise about which guard tripped.
  81. $rawFilter = '/\|\s*(raw|safe)\b/';
  82. $autoescape = '/\{\%\s*autoescape\b/';
  83. $offences = [];
  84. $scanned = 0;
  85. /** @var \SplFileInfo $file */
  86. foreach ($iter as $file) {
  87. if (!$file->isFile() || $file->getExtension() !== 'twig') {
  88. continue;
  89. }
  90. $scanned++;
  91. $contents = file_get_contents($file->getPathname());
  92. self::assertNotFalse($contents, "could not read {$file->getPathname()}");
  93. $lines = preg_split('/\R/', $contents) ?: [];
  94. foreach ($lines as $i => $line) {
  95. if (preg_match($rawFilter, $line, $m)) {
  96. $offences[] = sprintf(
  97. '%s:%d |%s filter — bypasses HTML autoescape (R01-N21)',
  98. self::relativePath($file->getPathname(), $viewsDir),
  99. $i + 1,
  100. $m[1],
  101. );
  102. }
  103. if (preg_match($autoescape, $line)) {
  104. $offences[] = sprintf(
  105. '%s:%d {%% autoescape … %%} override — bypasses HTML autoescape (R01-N21)',
  106. self::relativePath($file->getPathname(), $viewsDir),
  107. $i + 1,
  108. );
  109. }
  110. }
  111. }
  112. self::assertGreaterThan(0, $scanned, 'no .twig files were scanned — wrong views directory?');
  113. self::assertSame(
  114. [],
  115. $offences,
  116. "Forbidden escape-bypass usage in views:\n - " . implode("\n - ", $offences)
  117. . "\n\nIf one of these is genuinely safe (e.g., an HTML constant the operator controls),"
  118. . " add an allow-list comment alongside this test and document why the input is trusted.",
  119. );
  120. }
  121. private static function relativePath(string $abs, string $base): string
  122. {
  123. if (str_starts_with($abs, $base . '/')) {
  124. return substr($abs, strlen($base) + 1);
  125. }
  126. return $abs;
  127. }
  128. }