1
0

TwigAutoescapeTest.php 5.9 KB

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