| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152 |
- <?php
- /*
- * Copyright 2026 Alessandro Chiapparini <sprint_planer_web@chiapparini.org>
- * SPDX-License-Identifier: Apache-2.0
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * See the LICENSE file in the project root for the full license text.
- */
- declare(strict_types=1);
- namespace App\Tests\Http;
- use App\Http\View;
- use App\Tests\TestCase;
- use FilesystemIterator;
- use RecursiveDirectoryIterator;
- use RecursiveIteratorIterator;
- /**
- * R01-N21: Twig auto-escape is the only barrier between user-supplied
- * strings (sprint name, task title, audit JSON, …) and stored XSS in
- * the views that render them. One careless `|raw` or `{% autoescape
- * false %}` opens the door.
- *
- * Two complementary checks:
- *
- * - **Behaviour pin**: render a known XSS payload through a synthetic
- * Twig template using the same `View` env the controllers use, and
- * assert the output is HTML-escaped. This catches a future change
- * to `View`'s `autoescape` config (e.g. someone flips it to `'name'`
- * so a template-name-derived strategy kicks in).
- *
- * - **Static guard**: scan every `views` `.twig` template for the
- * escape-bypass forms — `|raw`, `|safe`, `{% autoescape false %}`,
- * and any `{% autoescape ... %}` override — and fail if any are
- * present. Tests like this beat code review when the codebase
- * grows.
- */
- final class TwigAutoescapeTest extends TestCase
- {
- public function testHtmlAutoescapeEscapesXssPayloadEndToEnd(): void
- {
- $view = new View(__DIR__ . '/../../views');
- $twig = $view->twig();
- $payload = '<script>alert(1)</script>';
- $tpl = $twig->createTemplate('{{ x }}');
- $out = $tpl->render(['x' => $payload]);
- // The literal `<script>` opener must NOT survive — a real
- // autoescape=html env will produce `<script>…`. We
- // assert both the negative (no raw tag) and the positive
- // (the entity-encoded form is present) so a flip to
- // `autoescape: false` would fail loudly.
- self::assertStringNotContainsString(
- '<script>',
- $out,
- 'autoescape=html must HTML-escape user values rendered with {{ }}',
- );
- self::assertStringContainsString('<script>', $out);
- self::assertStringContainsString('</script>', $out);
- }
- public function testAttributeContextEscapesQuoteCharacters(): void
- {
- // The `e('html_attr')` filter is the documented attribute-context
- // recipe in this codebase. Auto-escape inside `"…"` attribute
- // values defaults to HTML escape, which is enough for the
- // double-quote → `"` replacement that closes the attribute
- // injection vector.
- $view = new View(__DIR__ . '/../../views');
- $twig = $view->twig();
- $tpl = $twig->createTemplate('<div title="{{ x }}"></div>');
- $out = $tpl->render(['x' => '" onmouseover="alert(1)']);
- self::assertStringNotContainsString('onmouseover="alert(1)', $out);
- self::assertStringContainsString('"', $out);
- }
- /**
- * Scan every `.twig` file under `views` for escape-bypass forms.
- * A future template with `|raw` (or `{% autoescape false %}`)
- * would make stored XSS one careless commit away — this test
- * fails fast with the offending lines so the reviewer sees
- * them in CI.
- */
- public function testNoRawSafeOrAutoescapeOverrideInViews(): void
- {
- $viewsDir = realpath(__DIR__ . '/../../views');
- self::assertNotFalse($viewsDir, 'views/ must exist');
- $iter = new RecursiveIteratorIterator(
- new RecursiveDirectoryIterator($viewsDir, FilesystemIterator::SKIP_DOTS)
- );
- // Two independent regexes — running both on the same line keeps
- // the failure message precise about which guard tripped.
- $rawFilter = '/\|\s*(raw|safe)\b/';
- $autoescape = '/\{\%\s*autoescape\b/';
- $offences = [];
- $scanned = 0;
- /** @var \SplFileInfo $file */
- foreach ($iter as $file) {
- if (!$file->isFile() || $file->getExtension() !== 'twig') {
- continue;
- }
- $scanned++;
- $contents = file_get_contents($file->getPathname());
- self::assertNotFalse($contents, "could not read {$file->getPathname()}");
- $lines = preg_split('/\R/', $contents) ?: [];
- foreach ($lines as $i => $line) {
- if (preg_match($rawFilter, $line, $m)) {
- $offences[] = sprintf(
- '%s:%d |%s filter — bypasses HTML autoescape (R01-N21)',
- self::relativePath($file->getPathname(), $viewsDir),
- $i + 1,
- $m[1],
- );
- }
- if (preg_match($autoescape, $line)) {
- $offences[] = sprintf(
- '%s:%d {%% autoescape … %%} override — bypasses HTML autoescape (R01-N21)',
- self::relativePath($file->getPathname(), $viewsDir),
- $i + 1,
- );
- }
- }
- }
- self::assertGreaterThan(0, $scanned, 'no .twig files were scanned — wrong views directory?');
- self::assertSame(
- [],
- $offences,
- "Forbidden escape-bypass usage in views:\n - " . implode("\n - ", $offences)
- . "\n\nIf one of these is genuinely safe (e.g., an HTML constant the operator controls),"
- . " add an allow-list comment alongside this test and document why the input is trusted.",
- );
- }
- private static function relativePath(string $abs, string $base): string
- {
- if (str_starts_with($abs, $base . '/')) {
- return substr($abs, strlen($base) + 1);
- }
- return $abs;
- }
- }
|