[Profiler] Escape template and profile names in HtmlDumper

The HtmlDumper output is intended to be rendered in a browser, and the
template and macro/block names it interpolates are loader-controlled
(e.g. the key for ArrayLoader or a database row id), so they can carry
arbitrary HTML when an application stores templates under user-supplied
identifiers.
This commit is contained in:
Nicolas Grekas
2026-05-15 19:17:28 +02:00
committed by Fabien Potencier
parent 50535718b8
commit a5f6e8793e
2 changed files with 27 additions and 2 deletions
+7 -2
View File
@@ -32,16 +32,21 @@ final class HtmlDumper extends BaseDumper
protected function formatTemplate(Profile $profile, $prefix): string
{
return \sprintf('%s└ <span style="background-color: %s">%s</span>', $prefix, self::$colors['template'], $profile->getTemplate());
return \sprintf('%s└ <span style="background-color: %s">%s</span>', $prefix, self::$colors['template'], self::escape($profile->getTemplate()));
}
protected function formatNonTemplate(Profile $profile, $prefix): string
{
return \sprintf('%s└ %s::%s(<span style="background-color: %s">%s</span>)', $prefix, $profile->getTemplate(), $profile->getType(), self::$colors[$profile->getType()] ?? 'auto', $profile->getName());
return \sprintf('%s└ %s::%s(<span style="background-color: %s">%s</span>)', $prefix, self::escape($profile->getTemplate()), $profile->getType(), self::$colors[$profile->getType()] ?? 'auto', self::escape($profile->getName()));
}
protected function formatTime(Profile $profile, $percent): string
{
return \sprintf('<span style="color: %s">%.2fms/%.0f%%</span>', $percent > 20 ? self::$colors['big'] : 'auto', $profile->getDuration() * 1000, $percent);
}
private static function escape(string $value): string
{
return htmlspecialchars($value, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8');
}
}
+20
View File
@@ -21,6 +21,7 @@ namespace Twig\Tests\Profiler\Dumper;
*/
use Twig\Profiler\Dumper\HtmlDumper;
use Twig\Profiler\Profile;
class HtmlTest extends ProfilerTestCase
{
@@ -39,4 +40,23 @@ class HtmlTest extends ProfilerTestCase
</pre>
EOF, $dumper->dump($this->getProfile()));
}
public function testDumpEscapesTemplateAndProfileNames()
{
$root = new Profile('main');
$child = new Profile('<img src=x onerror=alert(1)>', Profile::TEMPLATE);
$grandchild = new Profile('<img src=x onerror=alert(2)>', Profile::MACRO, '<img src=x onerror=alert(3)>');
(new \ReflectionProperty($child, 'profiles'))->setValue($child, [$grandchild]);
(new \ReflectionProperty($root, 'profiles'))->setValue($root, [$child]);
$output = (new HtmlDumper())->dump($root);
$this->assertStringNotContainsString('<img src=x onerror=alert(1)>', $output);
$this->assertStringNotContainsString('<img src=x onerror=alert(2)>', $output);
$this->assertStringNotContainsString('<img src=x onerror=alert(3)>', $output);
$this->assertStringContainsString('&lt;img src=x onerror=alert(1)&gt;', $output);
$this->assertStringContainsString('&lt;img src=x onerror=alert(2)&gt;', $output);
$this->assertStringContainsString('&lt;img src=x onerror=alert(3)&gt;', $output);
}
}