Symfony potential Cross-site Scripting vulnerabilities in CodeExtension filters
Description
Symfony is a PHP framework for web and console applications and a set of reusable PHP components. Starting in versions 2.0.0, 5.0.0, and 6.0.0 and prior to versions 4.4.51, 5.4.31, and 6.3.8, some Twig filters in CodeExtension use is_safe=html but don't actually ensure their input is safe. As of versions 4.4.51, 5.4.31, and 6.3.8, Symfony now escapes the output of the affected filters.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
symfony/twig-bridgePackagist | >= 2.0.0, < 4.4.51 | 4.4.51 |
symfony/twig-bridgePackagist | >= 5.0.0, < 5.4.31 | 5.4.31 |
symfony/twig-bridgePackagist | >= 6.0.0, < 6.3.8 | 6.3.8 |
symfony/symfonyPackagist | >= 2.0.0, < 4.4.51 | 4.4.51 |
symfony/symfonyPackagist | >= 5.0.0, < 5.4.31 | 5.4.31 |
symfony/symfonyPackagist | >= 6.0.0, < 6.3.8 | 6.3.8 |
Affected products
1- Range: >= 2.0.0, < 4.4.51
Patches
25d095d5feb13security #cve-2023-46734 [TwigBridge] Ensure CodeExtension's filters properly escape their input (nicolas-grekas, GromNaN)
2 files changed · +133 −30
src/Symfony/Bridge/Twig/Extension/CodeExtension.php+14 −9 modified@@ -48,8 +48,8 @@ public function __construct($fileLinkFormat, string $projectDir, string $charset public function getFilters() { return [ - new TwigFilter('abbr_class', [$this, 'abbrClass'], ['is_safe' => ['html']]), - new TwigFilter('abbr_method', [$this, 'abbrMethod'], ['is_safe' => ['html']]), + new TwigFilter('abbr_class', [$this, 'abbrClass'], ['is_safe' => ['html'], 'pre_escape' => 'html']), + new TwigFilter('abbr_method', [$this, 'abbrMethod'], ['is_safe' => ['html'], 'pre_escape' => 'html']), new TwigFilter('format_args', [$this, 'formatArgs'], ['is_safe' => ['html']]), new TwigFilter('format_args_as_text', [$this, 'formatArgsAsText']), new TwigFilter('file_excerpt', [$this, 'fileExcerpt'], ['is_safe' => ['html']]), @@ -95,22 +95,23 @@ public function formatArgs($args) $result = []; foreach ($args as $key => $item) { if ('object' === $item[0]) { + $item[1] = htmlspecialchars($item[1], \ENT_COMPAT | \ENT_SUBSTITUTE, $this->charset); $parts = explode('\\', $item[1]); $short = array_pop($parts); $formattedValue = sprintf('<em>object</em>(<abbr title="%s">%s</abbr>)', $item[1], $short); } elseif ('array' === $item[0]) { - $formattedValue = sprintf('<em>array</em>(%s)', \is_array($item[1]) ? $this->formatArgs($item[1]) : $item[1]); + $formattedValue = sprintf('<em>array</em>(%s)', \is_array($item[1]) ? $this->formatArgs($item[1]) : htmlspecialchars(var_export($item[1], true), \ENT_COMPAT | \ENT_SUBSTITUTE, $this->charset)); } elseif ('null' === $item[0]) { $formattedValue = '<em>null</em>'; } elseif ('boolean' === $item[0]) { - $formattedValue = '<em>'.strtolower(var_export($item[1], true)).'</em>'; + $formattedValue = '<em>'.strtolower(htmlspecialchars(var_export($item[1], true), \ENT_COMPAT | \ENT_SUBSTITUTE, $this->charset)).'</em>'; } elseif ('resource' === $item[0]) { $formattedValue = '<em>resource</em>'; } else { $formattedValue = str_replace("\n", '', htmlspecialchars(var_export($item[1], true), \ENT_COMPAT | \ENT_SUBSTITUTE, $this->charset)); } - $result[] = \is_int($key) ? $formattedValue : sprintf("'%s' => %s", $key, $formattedValue); + $result[] = \is_int($key) ? $formattedValue : sprintf("'%s' => %s", htmlspecialchars($key, \ENT_COMPAT | \ENT_SUBSTITUTE, $this->charset), $formattedValue); } return implode(', ', $result); @@ -178,13 +179,17 @@ public function fileExcerpt($file, $line, $srcContext = 3) public function formatFile($file, $line, $text = null) { $file = trim($file); + $line = (int) $line; if (null === $text) { - $text = $file; - if (null !== $rel = $this->getFileRelative($text)) { - $rel = explode('/', $rel, 2); - $text = sprintf('<abbr title="%s%2$s">%s</abbr>%s', $this->projectDir, $rel[0], '/'.($rel[1] ?? '')); + if (null !== $rel = $this->getFileRelative($file)) { + $rel = explode('/', htmlspecialchars($rel, \ENT_COMPAT | \ENT_SUBSTITUTE, $this->charset), 2); + $text = sprintf('<abbr title="%s%2$s">%s</abbr>%s', htmlspecialchars($this->projectDir, \ENT_COMPAT | \ENT_SUBSTITUTE, $this->charset), $rel[0], '/'.($rel[1] ?? '')); + } else { + $text = htmlspecialchars($file, \ENT_COMPAT | \ENT_SUBSTITUTE, $this->charset); } + } else { + $text = htmlspecialchars($text, \ENT_COMPAT | \ENT_SUBSTITUTE, $this->charset); } if (0 < $line) {
src/Symfony/Bridge/Twig/Tests/Extension/CodeExtensionTest.php+119 −21 modified@@ -14,6 +14,8 @@ use PHPUnit\Framework\TestCase; use Symfony\Bridge\Twig\Extension\CodeExtension; use Symfony\Component\HttpKernel\Debug\FileLinkFormatter; +use Twig\Environment; +use Twig\Loader\ArrayLoader; class CodeExtensionTest extends TestCase { @@ -28,38 +30,123 @@ public function testFileRelative() $this->assertEquals('file.txt', $this->getExtension()->getFileRelative(\DIRECTORY_SEPARATOR.'project'.\DIRECTORY_SEPARATOR.'file.txt')); } - /** - * @dataProvider getClassNameProvider - */ - public function testGettingClassAbbreviation($class, $abbr) + public function testClassAbbreviationIntegration() { - $this->assertEquals($this->getExtension()->abbrClass($class), $abbr); + $data = [ + 'fqcn' => 'F\Q\N\Foo', + 'xss' => '<script>', + ]; + + $template = <<<'TWIG' +{{ 'Bare'|abbr_class }} +{{ fqcn|abbr_class }} +{{ xss|abbr_class }} +TWIG; + + $expected = <<<'HTML' +<abbr title="Bare">Bare</abbr> +<abbr title="F\Q\N\Foo">Foo</abbr> +<abbr title="<script>"><script></abbr> +HTML; + + $this->assertEquals($expected, $this->render($template, $data)); } - /** - * @dataProvider getMethodNameProvider - */ - public function testGettingMethodAbbreviation($method, $abbr) + public function testMethodAbbreviationIntegration() { - $this->assertEquals($this->getExtension()->abbrMethod($method), $abbr); + $data = [ + 'fqcn' => 'F\Q\N\Foo::Method', + 'xss' => '<script>', + ]; + + $template = <<<'TWIG' +{{ 'Bare::Method'|abbr_method }} +{{ fqcn|abbr_method }} +{{ 'Closure'|abbr_method }} +{{ 'Method'|abbr_method }} +{{ xss|abbr_method }} +TWIG; + + $expected = <<<'HTML' +<abbr title="Bare">Bare</abbr>::Method() +<abbr title="F\Q\N\Foo">Foo</abbr>::Method() +<abbr title="Closure">Closure</abbr> +<abbr title="Method">Method</abbr>() +<abbr title="<script>"><script></abbr>() +HTML; + + $this->assertEquals($expected, $this->render($template, $data)); } - public function getClassNameProvider() + public function testFormatArgsIntegration() { - return [ - ['F\Q\N\Foo', '<abbr title="F\Q\N\Foo">Foo</abbr>'], - ['Bare', '<abbr title="Bare">Bare</abbr>'], + $data = [ + 'args' => [ + ['object', 'Foo'], + ['array', [['string', 'foo'], ['null']]], + ['resource'], + ['string', 'bar'], + ['int', 123], + ['bool', true], + ], + 'xss' => [ + ['object', '<Foo>'], + ['array', [['string', '<foo>']]], + ['string', '<bar>'], + ['int', 123], + ['bool', true], + ['<xss>', '<script>'], + ], ]; + + $template = <<<'TWIG' +{{ args|format_args }} +{{ xss|format_args }} +{{ args|format_args_as_text }} +{{ xss|format_args_as_text }} +TWIG; + + $expected = <<<'HTML' +<em>object</em>(<abbr title="Foo">Foo</abbr>), <em>array</em>('foo', <em>null</em>), <em>resource</em>, 'bar', 123, true +<em>object</em>(<abbr title="<Foo>"><Foo></abbr>), <em>array</em>('<foo>'), '<bar>', 123, true, '<script>' +object(Foo), array('foo', null), resource, 'bar', 123, true +object(&lt;Foo&gt;), array('&lt;foo&gt;'), '&lt;bar&gt;', 123, true, '&lt;script&gt;' +HTML; + + $this->assertEquals($expected, $this->render($template, $data)); + } + + + public function testFormatFileIntegration() + { + $template = <<<'TWIG' +{{ 'foo/bar/baz.php'|format_file(21) }} +{{ '<script>'|format_file('<script21>') }} +TWIG; + + $expected = <<<'HTML' +<a href="proto://foo/bar/baz.php#&line=21" title="Click to open this file" class="file_link">foo/bar/baz.php at line 21</a> +<a href="proto://<script>#&line=0" title="Click to open this file" class="file_link"><script></a> +HTML; + + $this->assertEquals($expected, $this->render($template)); } - public function getMethodNameProvider() + public function testFormatFileFromTextIntegration() { - return [ - ['F\Q\N\Foo::Method', '<abbr title="F\Q\N\Foo">Foo</abbr>::Method()'], - ['Bare::Method', '<abbr title="Bare">Bare</abbr>::Method()'], - ['Closure', '<abbr title="Closure">Closure</abbr>'], - ['Method', '<abbr title="Method">Method</abbr>()'], - ]; + $template = <<<'TWIG' +{{ 'in "foo/bar/baz.php" at line 21'|format_file_from_text }} +{{ 'in "foo/bar/baz.php" on line 21'|format_file_from_text }} +{{ 'in "<script>" on line 21'|format_file_from_text }} +TWIG; + + $expected = <<<'HTML' +in <a href="proto://foo/bar/baz.php#&line=21" title="Click to open this file" class="file_link">foo/bar/baz.php at line 21</a> +in <a href="proto://foo/bar/baz.php#&line=21" title="Click to open this file" class="file_link">foo/bar/baz.php at line 21</a> +in <a href="proto://<script>#&line=21" title="Click to open this file" class="file_link"><script> at line 21</a> +HTML; + + $this->assertEquals($expected, $this->render($template)); } public function testGetName() @@ -71,4 +158,15 @@ protected function getExtension() { return new CodeExtension(new FileLinkFormatter('proto://%f#&line=%l&'.substr(__FILE__, 0, 5).'>foobar'), \DIRECTORY_SEPARATOR.'project', 'UTF-8'); } + + private function render(string $template, array $context = []) + { + $twig = new Environment( + new ArrayLoader(['index' => $template]), + ['debug' => true] + ); + $twig->addExtension($this->getExtension()); + + return $twig->render('index', $context); + } }
9da9a145ce57[TwigBridge] Ensure CodeExtension's filters properly escape their input
1 file changed · +14 −9
src/Symfony/Bridge/Twig/Extension/CodeExtension.php+14 −9 modified@@ -48,8 +48,8 @@ public function __construct($fileLinkFormat, string $projectDir, string $charset public function getFilters() { return [ - new TwigFilter('abbr_class', [$this, 'abbrClass'], ['is_safe' => ['html']]), - new TwigFilter('abbr_method', [$this, 'abbrMethod'], ['is_safe' => ['html']]), + new TwigFilter('abbr_class', [$this, 'abbrClass'], ['is_safe' => ['html'], 'pre_escape' => 'html']), + new TwigFilter('abbr_method', [$this, 'abbrMethod'], ['is_safe' => ['html'], 'pre_escape' => 'html']), new TwigFilter('format_args', [$this, 'formatArgs'], ['is_safe' => ['html']]), new TwigFilter('format_args_as_text', [$this, 'formatArgsAsText']), new TwigFilter('file_excerpt', [$this, 'fileExcerpt'], ['is_safe' => ['html']]), @@ -95,22 +95,23 @@ public function formatArgs($args) $result = []; foreach ($args as $key => $item) { if ('object' === $item[0]) { + $item[1] = htmlspecialchars($item[1], \ENT_COMPAT | \ENT_SUBSTITUTE, $this->charset); $parts = explode('\\', $item[1]); $short = array_pop($parts); $formattedValue = sprintf('<em>object</em>(<abbr title="%s">%s</abbr>)', $item[1], $short); } elseif ('array' === $item[0]) { - $formattedValue = sprintf('<em>array</em>(%s)', \is_array($item[1]) ? $this->formatArgs($item[1]) : $item[1]); + $formattedValue = sprintf('<em>array</em>(%s)', \is_array($item[1]) ? $this->formatArgs($item[1]) : htmlspecialchars(var_export($item[1], true), \ENT_COMPAT | \ENT_SUBSTITUTE, $this->charset)); } elseif ('null' === $item[0]) { $formattedValue = '<em>null</em>'; } elseif ('boolean' === $item[0]) { - $formattedValue = '<em>'.strtolower(var_export($item[1], true)).'</em>'; + $formattedValue = '<em>'.strtolower(htmlspecialchars(var_export($item[1], true), \ENT_COMPAT | \ENT_SUBSTITUTE, $this->charset)).'</em>'; } elseif ('resource' === $item[0]) { $formattedValue = '<em>resource</em>'; } else { $formattedValue = str_replace("\n", '', htmlspecialchars(var_export($item[1], true), \ENT_COMPAT | \ENT_SUBSTITUTE, $this->charset)); } - $result[] = \is_int($key) ? $formattedValue : sprintf("'%s' => %s", $key, $formattedValue); + $result[] = \is_int($key) ? $formattedValue : sprintf("'%s' => %s", htmlspecialchars($key, \ENT_COMPAT | \ENT_SUBSTITUTE, $this->charset), $formattedValue); } return implode(', ', $result); @@ -178,13 +179,17 @@ public function fileExcerpt($file, $line, $srcContext = 3) public function formatFile($file, $line, $text = null) { $file = trim($file); + $line = (int) $line; if (null === $text) { - $text = $file; - if (null !== $rel = $this->getFileRelative($text)) { - $rel = explode('/', $rel, 2); - $text = sprintf('<abbr title="%s%2$s">%s</abbr>%s', $this->projectDir, $rel[0], '/'.($rel[1] ?? '')); + if (null !== $rel = $this->getFileRelative($file)) { + $rel = explode('/', htmlspecialchars($rel, \ENT_COMPAT | \ENT_SUBSTITUTE, $this->charset), 2); + $text = sprintf('<abbr title="%s%2$s">%s</abbr>%s', htmlspecialchars($this->projectDir, \ENT_COMPAT | \ENT_SUBSTITUTE, $this->charset), $rel[0], '/'.($rel[1] ?? '')); + } else { + $text = htmlspecialchars($file, \ENT_COMPAT | \ENT_SUBSTITUTE, $this->charset); } + } else { + $text = htmlspecialchars($text, \ENT_COMPAT | \ENT_SUBSTITUTE, $this->charset); } if (0 < $line) {
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
8- github.com/advisories/GHSA-q847-2q57-wmr3ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2023-46734ghsaADVISORY
- github.com/FriendsOfPHP/security-advisories/blob/master/symfony/symfony/CVE-2023-46734.yamlghsaWEB
- github.com/symfony/symfony/commit/5d095d5feb1322b16450284a04d6bb48d1198f54ghsax_refsource_MISCWEB
- github.com/symfony/symfony/commit/9da9a145ce57e4585031ad4bee37c497353eec7cghsax_refsource_MISCWEB
- github.com/symfony/symfony/security/advisories/GHSA-q847-2q57-wmr3ghsax_refsource_CONFIRMWEB
- lists.debian.org/debian-lts-announce/2023/11/msg00019.htmlghsaWEB
- symfony.com/cve-2023-46734ghsaWEB
News mentions
0No linked articles in our index yet.