Contao has insufficient BBCode sanitizer
Description
Contao CMS before 4.13.40 and 5.3.4 allows injection of CSS styles via BBCode in comments, potentially leading to UI redressing or defacement.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Contao CMS before 4.13.40 and 5.3.4 allows injection of CSS styles via BBCode in comments, potentially leading to UI redressing or defacement.
Vulnerability
Description
CVE-2024-28234 is a CSS injection vulnerability in the Contao open source content management system. It affects all versions from 2.0.0 up to, but not including, 4.13.40 and 5.3.4 [1]. The bug exists in the BBCode processing logic within the CommentsBundle, where user-supplied BBCode tags are converted to HTML without sufficient sanitization of style attributes [2][3]. Specifically, the color tag and other attributes can be misused to inject arbitrary CSS properties, potentially leading to malicious styling of comment elements [2][3].
Exploitation
Conditions
Exploitation requires that BBCode is enabled for comments, which is a configurable option in Contao installations [1]. An attacker does not need special privileges; any user able to post comments on a public-facing site can craft a comment containing BBCode that injects CSS. The injection occurs during comment rendering, meaning the attack is triggered when an administrator or visitor loads a page displaying the injected comment. No user interaction beyond viewing the affected page is required [1][2].
Impact
By injecting CSS, an attacker can alter the visual appearance of the website, potentially performing UI redressing (clickjacking) or defacement. This could be used to trick users into clicking hidden elements, such as links or forms, leading to further compromise. The attacker does not gain code execution or direct data access, but the visual manipulation can serve as a stepping stone for phishing or social engineering attacks [1][2].
Mitigation
Status
Contao has released patches in versions 4.13.40 and 5.3.4 that address the issue by sanitizing BBCode output more strictly [1][2][3]. As a workaround, administrators can disable BBCode for comments entirely, which eliminates the attack vector [1]. Users are strongly advised to upgrade to the latest patched version or apply the workaround [1].
AI Insight generated on May 20, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
contao/comments-bundlePackagist | >= 2.0.0, < 4.13.40 | 4.13.40 |
contao/comments-bundlePackagist | >= 5.0.0-RC1, < 5.3.4 | 5.3.4 |
Affected products
2- Range: >= 2.0.0, < 4.13.40
Patches
255b995d8d35dMerge pull request from GHSA-j55w-hjpj-825g
5 files changed · +483 −58
comments-bundle/contao/classes/Comments.php+2 −58 modified@@ -10,6 +10,7 @@ namespace Contao; +use Contao\CommentsBundle\Util\BbCode; use Contao\CoreBundle\EventListener\Widget\HttpUrlListener; use Contao\CoreBundle\Exception\PageNotFoundException; use Contao\CoreBundle\Util\UrlUtil; @@ -408,70 +409,13 @@ protected function renderCommentForm(FrontendTemplate $objTemplate, \stdClass $o /** * Replace bbcode and return the HTML string * - * Supports the following tags: - * - * * [b][/b] bold - * * [i][/i] italic - * * [u][/u] underline - * * [img][/img] - * * [code][/code] - * * [color=#ff0000][/color] - * * [quote][/quote] - * * [quote=tim][/quote] - * * [url][/url] - * * [url=http://][/url] - * * [email][/email] - * * [email=name@example.com][/email] - * * @param string $strComment * * @return string */ public function parseBbCode($strComment) { - $arrSearch = array - ( - '@\[b\](.*)\[/b\]@Uis', - '@\[i\](.*)\[/i\]@Uis', - '@\[u\](.*)\[/u\]@Uis', - '@\s*\[code\](.*)\[/code\]\s*@Uis', - '@\[color=([^\]" ]+)\](.*)\[/color\]@Uis', - '@\s*\[quote\](.*)\[/quote\]\s*@Uis', - '@\s*\[quote=([^\]]+)\](.*)\[/quote\]\s*@Uis', - '@\[img\]\s*([^\[" ]+\.(jpe?g|png|gif|bmp|tiff?|ico))\s*\[/img\]@i', - '@\[url\]\s*([^\[" ]+)\s*\[/url\]@i', - '@\[url=([^\]" ]+)\](.*)\[/url\]@Uis', - '@\[email\]\s*([^\[" ]+)\s*\[/email\]@i', - '@\[email=([^\]" ]+)\](.*)\[/email\]@Uis', - '@href="(([a-z0-9]+\.)*[a-z0-9]+\.([a-z]{2}|asia|biz|com|info|name|net|org|tel)(/|"))@i' - ); - - $arrReplace = array - ( - '<strong>$1</strong>', - '<em>$1</em>', - '<span style="text-decoration:underline">$1</span>', - "\n\n" . '<div class="code"><p>' . $GLOBALS['TL_LANG']['MSC']['com_code'] . '</p><pre>$1</pre></div>' . "\n\n", - '<span style="color:$1">$2</span>', - "\n\n" . '<blockquote>$1</blockquote>' . "\n\n", - "\n\n" . '<blockquote><p>' . sprintf($GLOBALS['TL_LANG']['MSC']['com_quote'], '$1') . '</p>$2</blockquote>' . "\n\n", - '<img src="$1" alt="" />', - '<a href="$1">$1</a>', - '<a href="$1">$2</a>', - '<a href="mailto:$1">$1</a>', - '<a href="mailto:$1">$2</a>', - 'href="http://$1' - ); - - $strComment = preg_replace($arrSearch, $arrReplace, $strComment); - - // Encode e-mail addresses - if (str_contains($strComment, 'mailto:')) - { - $strComment = StringUtil::encodeEmail($strComment); - } - - return $strComment; + return (new BbCode())->toHtml($strComment); } /**
comments-bundle/src/Util/BbCode.php+229 −0 added@@ -0,0 +1,229 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of Contao. + * + * (c) Leo Feyer + * + * @license LGPL-3.0-or-later + */ + +namespace Contao\CommentsBundle\Util; + +use Contao\Idna; +use Contao\StringUtil; +use Contao\Validator; + +/** + * @internal + */ +final class BbCode +{ + /** + * Converts text containing BBCode to HTML. + * + * Supports the following tags: + * + * * [b][/b] bold + * * [i][/i] italic + * * [u][/u] underline + * * [code][/code] + * * [quote][/quote] + * * [quote=author][/quote] + * * [url][/url] + * * [url=https://…][/url] + * * [email][/email] + * * [email=name@example.com][/email] + */ + public function toHtml(string $bbCode): string + { + return str_replace(['{', '}'], ['{', '}'], $this->compile($this->parse($this->tokenize($bbCode), $bbCode))); + } + + /** + * Find BBCode tokens and annotate them with their position/tag/type and + * attribute. We're only matching tokens in the form '[tag]', '[/tag]' and + * '[tag=attr]'. + */ + private function tokenize(string $input): array + { + if (false === preg_match_all('%\[(/?)(b|i|u|quote|code|url|email|img|color)(?:=([^\[\]]*))?]%', $input, $matches, PREG_OFFSET_CAPTURE)) { + throw new \InvalidArgumentException('Could not tokenize input.'); + } + + $tokens = []; + + foreach ($matches[0] as $index => [$token, $position]) { + $tokens[] = [ + 'start' => $position, + 'end' => $position + \strlen($token), + 'closing' => '/' === $matches[1][$index][0], + 'tag' => $matches[2][$index][0], + 'attr' => $matches[3][$index][0] ?: null, + ]; + } + + return $tokens; + } + + /** + * Parses tokens into a node tree. Input before/after tokens is treated as + * text. + */ + private function parse(array $tokens, string $input): Node + { + $root = new Node(); + $node = $root; + $tags = []; + $position = 0; + + $addNode = static function (Node $parent, $type): Node { + $node = new Node($parent, $type); + $parent->children[] = $node; + + return $node; + }; + + $advance = static function (array $token) use (&$position): void { + $position = $token['end']; + }; + + $numTokens = \count($tokens); + + for ($i = 0; $i < $numTokens; ++$i) { + $current = $tokens[$i]; + + // Text before token + if (($length = $current['start'] - $position) > 0) { + $addNode($node, Node::TYPE_TEXT)->setValue(substr($input, $position, $length)); + } + + // Code + if (('code' === $current['tag']) && !$current['closing']) { + for ($j = $i + 1; $j < $numTokens; ++$j) { + if ('code' === $tokens[$j]['tag'] && $tokens[$j]['closing']) { + $addNode($root, Node::TYPE_CODE)->setValue(substr($input, $current['end'], $tokens[$j]['start'] - $current['end'])); + $advance($tokens[$j]); + $i = $j; + continue 2; + } + } + } + + // Blocks + $onTagStack = \in_array($current['tag'], $tags, true); + + if (\in_array($current['tag'], ['b', 'i', 'u', 'url', 'email'], true)) { + if (!$current['closing'] && !$onTagStack) { + $node = $addNode($node, Node::TYPE_BLOCK)->setTag($current['tag'])->setValue($current['attr']); + $tags[] = $current['tag']; + } elseif ($current['closing'] && $onTagStack) { + do { + $node = $node->parent; + } while ($current['tag'] !== array_pop($tags)); + } + } elseif ('quote' === $current['tag']) { + if (!$current['closing'] && !$onTagStack) { + $node = $addNode($root, Node::TYPE_BLOCK)->setTag($current['tag'])->setValue($current['attr']); + $tags = [$current['tag']]; + } elseif ($current['closing'] && $onTagStack) { + $node = $node->parent; + $tags = []; + } + } + + $advance($current); + } + + // Text after last token + if ('' !== ($text = substr($input, $position))) { + $addNode($root, Node::TYPE_TEXT)->setValue($text); + } + + return $root; + } + + /** + * Compiles a node (tree) back into a string. + */ + private function compile(Node $node): string + { + if (Node::TYPE_ROOT === $node->type) { + return $this->subCompile($node->children); + } + + if (Node::TYPE_BLOCK === $node->type) { + if ('' === ($children = $this->subCompile($node->children))) { + return ''; + } + + switch ($node->tag) { + case 'b': + return sprintf('<strong>%s</strong>', $children); + + case 'i': + return sprintf('<em>%s</em>', $children); + + case 'u': + return sprintf('<span style="text-decoration: underline">%s</span>', $children); + + case 'quote': + if (null !== $node->value) { + return sprintf( + '<blockquote><p>%s</p>%s</blockquote>', + sprintf($GLOBALS['TL_LANG']['MSC']['com_quote'], StringUtil::specialchars($node->value, true)), + $children, + ); + } + + return sprintf('<blockquote>%s</blockquote>', $children); + + case 'email': + $uri = $node->value ?: $node->getFirstChildValue() ?? ''; + $title = $node->value ? $children : $uri; + + try { + if (Validator::isEmail($uri)) { + return sprintf('<a href="mailto:%s">%s</a>', StringUtil::specialchars(Idna::encodeEmail($uri), true), StringUtil::specialchars($title, true)); + } + } catch (\InvalidArgumentException) { + } + + return StringUtil::specialchars($title, true); + + case 'url': + $uri = $node->value ?: $node->getFirstChildValue() ?? ''; + $title = $node->value ? $children : $uri; + + try { + if (Validator::isUrl($uri)) { + return sprintf('<a href="%s" rel="noopener noreferrer nofollow">%s</a>', StringUtil::specialchars(Idna::encodeUrl($uri), true), StringUtil::specialchars($title, true)); + } + } catch (\InvalidArgumentException) { + } + + return StringUtil::specialchars($title, true); + + default: + throw new \RuntimeException('Invalid block value.'); + } + } + + if (Node::TYPE_CODE === $node->type) { + return sprintf('<div class="code"><p>%s</p><pre>%s</pre></div>', $GLOBALS['TL_LANG']['MSC']['com_code'], StringUtil::specialchars($node->value, true)); + } + + if (Node::TYPE_TEXT === $node->type) { + return StringUtil::specialchars($node->value, true); + } + + throw new \RuntimeException('Invalid node type.'); + } + + private function subCompile(array $nodes): string + { + return implode('', array_map(fn (Node $node): string => $this->compile($node), $nodes)); + } +}
comments-bundle/src/Util/Node.php+65 −0 added@@ -0,0 +1,65 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of Contao. + * + * (c) Leo Feyer + * + * @license LGPL-3.0-or-later + */ + +namespace Contao\CommentsBundle\Util; + +/** + * @internal + */ +final class Node +{ + public const TYPE_ROOT = 0; + + public const TYPE_TEXT = 1; + + public const TYPE_BLOCK = 2; + + public const TYPE_CODE = 3; + + public string|null $tag = null; + + public string|null $value = null; + + /** + * @var array<Node> + */ + public array $children = []; + + public function __construct( + public self|null $parent = null, + public int $type = self::TYPE_ROOT, + ) { + } + + public function setTag(string $tag): self + { + $this->tag = $tag; + + return $this; + } + + public function setValue(string|null $value): self + { + $this->value = $value; + + return $this; + } + + public function getFirstChildValue(): string|null + { + if ([] === $this->children) { + return null; + } + + return $this->children[0]->value; + } +}
comments-bundle/tests/Util/BbCodeTest.php+140 −0 added@@ -0,0 +1,140 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of Contao. + * + * (c) Leo Feyer + * + * @license LGPL-3.0-or-later + */ + +use Contao\CommentsBundle\Util\BbCode; +use PHPUnit\Framework\TestCase; + +class BbCodeTest extends TestCase +{ + /** + * @dataProvider provideBbCode + */ + public function testConvertToHtml(string $bbCode, string $expectedHtml): void + { + $GLOBALS['TL_LANG']['MSC'] = [ + 'com_quote' => '%s wrote:', + 'com_code' => 'Code:', + ]; + + $this->assertSame($expectedHtml, (new BbCode())->toHtml($bbCode)); + + unset($GLOBALS['TL_LANG']); + } + + public function provideBbCode(): Generator + { + yield 'transforms b,i and u tags' => [ + 'This should be [b]strong,[/b] [i]italic[/i] and [u]underlined[/u].', + 'This should be <strong>strong,</strong> <em>italic</em> and <span style="text-decoration: underline">underlined</span>.', + ]; + + yield 'ignores non-opened tags' => [ + 'foo[/i] bar', + 'foo bar', + ]; + + yield 'ignores non-closed tags' => [ + 'foo [i]bar', + 'foo bar', + ]; + + yield 'ignores nesting the same tag ' => [ + '[i]foo [i]bar[/i][/i]', + '<em>foo bar</em>', + ]; + + yield 'resolves interleaved tags' => [ + '[i][b]foo[/i]bar[/b]', + '<em><strong>foo</strong></em>bar', + ]; + + yield 'transforms quote tags' => [ + '[quote]See? A "quote".[/quote]', + '<blockquote>See? A "quote".</blockquote>', + ]; + + yield 'transforms quote tags with author attribute' => [ + '[quote=Someone]I\d rather have [b]markdown[/b][/quote]', + '<blockquote><p>Someone wrote:</p>I\d rather have <strong>markdown</strong></blockquote>', + ]; + + yield 'ignores nested quotes' => [ + '[quote]A [quote]quote[/quote] of a quote[/quote]', + '<blockquote>A quote</blockquote> of a quote', + ]; + + yield 'only allows quotes on top level' => [ + 'A [b]strong [quote]statement[/quote]![/b]', + 'A <strong>strong </strong><blockquote>statement</blockquote>!', + ]; + + yield 'wraps code in pre tags' => [ + 'some [code]things without [b]formatting[/b][/code]', + 'some <div class="code"><p>Code:</p><pre>things without [b]formatting[/b]</pre></div>', + ]; + + yield 'only allows code on top level' => [ + '[i][code]no italic code?[/code][/i]', + '<div class="code"><p>Code:</p><pre>no italic code?</pre></div>', + ]; + + yield 'transforms url tags' => [ + '[url]https://example.com[/url] [url=https://example.com]my website[/url]', + '<a href="https://example.com" rel="noopener noreferrer nofollow">https://example.com</a> <a href="https://example.com" rel="noopener noreferrer nofollow">my website</a>', + ]; + + yield 'transforms email tags' => [ + '[email]foo@contao.org[/email] [email=foo@contao.org]my email address[/email]', + '<a href="mailto:foo@contao.org">foo@contao.org</a> <a href="mailto:foo@contao.org">my email address</a>', + ]; + + yield 'ignores invalid urls (no FQDN)' => [ + '[url=foobar]foobar[/url] [url]foo.org[/url]', + 'foobar foo.org', + ]; + + yield 'ignores invalid email addresses' => [ + '[email=foobar]foobar[/email] [email]foobar[/email]', + 'foobar foobar', + ]; + + yield 'ignores img and color tag' => [ + '[color="red"]colored[/color] [img]image[/img]', + 'colored image', + ]; + + yield 'does not treat other things in brackets as tags' => [ + '[x] Yes or [ ] No? [o][/o]', + '[x] Yes or [ ] No? [o][/o]', + ]; + + yield 'replaces special chars' => [ + 'a&b{{no}}]<>\'":', + 'a&b]<>'":', + ]; + + yield 'encodes malicious email' => [ + '[email]"/onmouseenter=alert(1)>"@contao.org[/email]', + '<a href="mailto:"/onmouseenter=alert(1)>"@contao.org">"/onmouseenter=alert(1)>"@contao.org</a>', + ]; + + yield 'encodes URLs' => [ + '[url]https://example.com/foo&bar[/url]', + '<a href="https://example.com/foo&bar" rel="noopener noreferrer nofollow">https://example.com/foo&bar</a>', + ]; + + yield 'encodes insert tags' => [ + '{[url]{insert_bad}[url]}', + '{{insert_bad}}', + ]; + } +}
comments-bundle/tests/Util/NodeTest.php+47 −0 added@@ -0,0 +1,47 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of Contao. + * + * (c) Leo Feyer + * + * @license LGPL-3.0-or-later + */ + +use Contao\CommentsBundle\Util\Node; +use PHPUnit\Framework\TestCase; + +class NodeTest extends TestCase +{ + public function testSetsAndGetsValues(): void + { + $default = new Node(); + + $this->assertSame(Node::TYPE_ROOT, $default->type); + $this->assertNull($default->parent); + $this->assertNull($default->tag); + $this->assertNull($default->value); + $this->assertEmpty($default->children); + + $node = (new Node($default, Node::TYPE_CODE))->setTag('tag')->setValue('value'); + + $this->assertSame($default, $node->parent); + $this->assertSame(Node::TYPE_CODE, $node->type); + $this->assertSame('tag', $node->tag); + $this->assertSame('value', $node->value); + } + + public function testGetsFirstChildValue(): void + { + $node = new Node(); + + $this->assertNull($node->getFirstChildValue()); + + $node->children[] = (new Node())->setValue('v1'); + $node->children[] = (new Node())->setValue('v2'); + + $this->assertSame('v1', $node->getFirstChildValue()); + } +}
6d42e667177cMerge pull request from GHSA-j55w-hjpj-825g
5 files changed · +496 −58
comments-bundle/src/Resources/contao/classes/Comments.php+2 −58 modified@@ -10,6 +10,7 @@ namespace Contao; +use Contao\CommentsBundle\Util\BbCode; use Contao\CoreBundle\EventListener\Widget\HttpUrlListener; use Contao\CoreBundle\Exception\PageNotFoundException; use Nyholm\Psr7\Uri; @@ -417,70 +418,13 @@ protected function renderCommentForm(FrontendTemplate $objTemplate, \stdClass $o /** * Replace bbcode and return the HTML string * - * Supports the following tags: - * - * * [b][/b] bold - * * [i][/i] italic - * * [u][/u] underline - * * [img][/img] - * * [code][/code] - * * [color=#ff0000][/color] - * * [quote][/quote] - * * [quote=tim][/quote] - * * [url][/url] - * * [url=http://][/url] - * * [email][/email] - * * [email=name@example.com][/email] - * * @param string $strComment * * @return string */ public function parseBbCode($strComment) { - $arrSearch = array - ( - '@\[b\](.*)\[/b\]@Uis', - '@\[i\](.*)\[/i\]@Uis', - '@\[u\](.*)\[/u\]@Uis', - '@\s*\[code\](.*)\[/code\]\s*@Uis', - '@\[color=([^\]" ]+)\](.*)\[/color\]@Uis', - '@\s*\[quote\](.*)\[/quote\]\s*@Uis', - '@\s*\[quote=([^\]]+)\](.*)\[/quote\]\s*@Uis', - '@\[img\]\s*([^\[" ]+\.(jpe?g|png|gif|bmp|tiff?|ico))\s*\[/img\]@i', - '@\[url\]\s*([^\[" ]+)\s*\[/url\]@i', - '@\[url=([^\]" ]+)\](.*)\[/url\]@Uis', - '@\[email\]\s*([^\[" ]+)\s*\[/email\]@i', - '@\[email=([^\]" ]+)\](.*)\[/email\]@Uis', - '@href="(([a-z0-9]+\.)*[a-z0-9]+\.([a-z]{2}|asia|biz|com|info|name|net|org|tel)(/|"))@i' - ); - - $arrReplace = array - ( - '<strong>$1</strong>', - '<em>$1</em>', - '<span style="text-decoration:underline">$1</span>', - "\n\n" . '<div class="code"><p>' . $GLOBALS['TL_LANG']['MSC']['com_code'] . '</p><pre>$1</pre></div>' . "\n\n", - '<span style="color:$1">$2</span>', - "\n\n" . '<blockquote>$1</blockquote>' . "\n\n", - "\n\n" . '<blockquote><p>' . sprintf($GLOBALS['TL_LANG']['MSC']['com_quote'], '$1') . '</p>$2</blockquote>' . "\n\n", - '<img src="$1" alt="" />', - '<a href="$1">$1</a>', - '<a href="$1">$2</a>', - '<a href="mailto:$1">$1</a>', - '<a href="mailto:$1">$2</a>', - 'href="http://$1' - ); - - $strComment = preg_replace($arrSearch, $arrReplace, $strComment); - - // Encode e-mail addresses - if (strpos($strComment, 'mailto:') !== false) - { - $strComment = StringUtil::encodeEmail($strComment); - } - - return $strComment; + return (new BbCode())->toHtml($strComment); } /**
comments-bundle/src/Util/BbCode.php+229 −0 added@@ -0,0 +1,229 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of Contao. + * + * (c) Leo Feyer + * + * @license LGPL-3.0-or-later + */ + +namespace Contao\CommentsBundle\Util; + +use Contao\Idna; +use Contao\StringUtil; +use Contao\Validator; + +/** + * @internal + */ +final class BbCode +{ + /** + * Converts text containing BBCode to HTML. + * + * Supports the following tags: + * + * * [b][/b] bold + * * [i][/i] italic + * * [u][/u] underline + * * [code][/code] + * * [quote][/quote] + * * [quote=author][/quote] + * * [url][/url] + * * [url=https://…][/url] + * * [email][/email] + * * [email=name@example.com][/email] + */ + public function toHtml(string $bbCode): string + { + return str_replace(['{', '}'], ['{', '}'], $this->compile($this->parse($this->tokenize($bbCode), $bbCode))); + } + + /** + * Find BBCode tokens and annotate them with their position/tag/type and + * attribute. We're only matching tokens in the form '[tag]', '[/tag]' and + * '[tag=attr]'. + */ + private function tokenize(string $input): array + { + if (false === preg_match_all('%\[(/?)(b|i|u|quote|code|url|email|img|color)(?:=([^\[\]]*))?]%', $input, $matches, PREG_OFFSET_CAPTURE)) { + throw new \InvalidArgumentException('Could not tokenize input.'); + } + + $tokens = []; + + foreach ($matches[0] as $index => [$token, $position]) { + $tokens[] = [ + 'start' => $position, + 'end' => $position + \strlen($token), + 'closing' => '/' === $matches[1][$index][0], + 'tag' => $matches[2][$index][0], + 'attr' => $matches[3][$index][0] ?: null, + ]; + } + + return $tokens; + } + + /** + * Parses tokens into a node tree. Input before/after tokens is treated as + * text. + */ + private function parse(array $tokens, string $input): Node + { + $root = new Node(); + $node = $root; + $tags = []; + $position = 0; + + $addNode = static function (Node $parent, $type): Node { + $node = new Node($parent, $type); + $parent->children[] = $node; + + return $node; + }; + + $advance = static function (array $token) use (&$position): void { + $position = $token['end']; + }; + + $numTokens = \count($tokens); + + for ($i = 0; $i < $numTokens; ++$i) { + $current = $tokens[$i]; + + // Text before token + if (($length = $current['start'] - $position) > 0) { + $addNode($node, Node::TYPE_TEXT)->setValue(substr($input, $position, $length)); + } + + // Code + if (('code' === $current['tag']) && !$current['closing']) { + for ($j = $i + 1; $j < $numTokens; ++$j) { + if ('code' === $tokens[$j]['tag'] && $tokens[$j]['closing']) { + $addNode($root, Node::TYPE_CODE)->setValue(substr($input, $current['end'], $tokens[$j]['start'] - $current['end'])); + $advance($tokens[$j]); + $i = $j; + continue 2; + } + } + } + + // Blocks + $onTagStack = \in_array($current['tag'], $tags, true); + + if (\in_array($current['tag'], ['b', 'i', 'u', 'url', 'email'], true)) { + if (!$current['closing'] && !$onTagStack) { + $node = $addNode($node, Node::TYPE_BLOCK)->setTag($current['tag'])->setValue($current['attr']); + $tags[] = $current['tag']; + } elseif ($current['closing'] && $onTagStack) { + do { + $node = $node->parent; + } while ($current['tag'] !== array_pop($tags)); + } + } elseif ('quote' === $current['tag']) { + if (!$current['closing'] && !$onTagStack) { + $node = $addNode($root, Node::TYPE_BLOCK)->setTag($current['tag'])->setValue($current['attr']); + $tags = [$current['tag']]; + } elseif ($current['closing'] && $onTagStack) { + $node = $node->parent; + $tags = []; + } + } + + $advance($current); + } + + // Text after last token + if ('' !== ($text = substr($input, $position))) { + $addNode($root, Node::TYPE_TEXT)->setValue($text); + } + + return $root; + } + + /** + * Compiles a node (tree) back into a string. + */ + private function compile(Node $node): string + { + if (Node::TYPE_ROOT === $node->type) { + return $this->subCompile($node->children); + } + + if (Node::TYPE_BLOCK === $node->type) { + if ('' === ($children = $this->subCompile($node->children))) { + return ''; + } + + switch ($node->tag) { + case 'b': + return sprintf('<strong>%s</strong>', $children); + + case 'i': + return sprintf('<em>%s</em>', $children); + + case 'u': + return sprintf('<span style="text-decoration: underline">%s</span>', $children); + + case 'quote': + if (null !== $node->value) { + return sprintf( + '<blockquote><p>%s</p>%s</blockquote>', + sprintf($GLOBALS['TL_LANG']['MSC']['com_quote'], StringUtil::specialchars($node->value, true)), + $children + ); + } + + return sprintf('<blockquote>%s</blockquote>', $children); + + case 'email': + $uri = $node->value ?: $node->getFirstChildValue() ?? ''; + $title = empty($node->value) ? $uri : $children; + + try { + if (Validator::isEmail($uri)) { + return sprintf('<a href="mailto:%s">%s</a>', StringUtil::specialchars(Idna::encodeEmail($uri), true), StringUtil::specialchars($title, true)); + } + } catch (\InvalidArgumentException $e) { + } + + return StringUtil::specialchars($title, true); + + case 'url': + $uri = $node->value ?: $node->getFirstChildValue() ?? ''; + $title = empty($node->value) ? $uri : $children; + + try { + if (Validator::isUrl($uri)) { + return sprintf('<a href="%s" rel="noopener noreferrer nofollow">%s</a>', StringUtil::specialchars(Idna::encodeUrl($uri), true), StringUtil::specialchars($title, true)); + } + } catch (\InvalidArgumentException $e) { + } + + return StringUtil::specialchars($title, true); + + default: + throw new \RuntimeException('Invalid block value.'); + } + } + + if (Node::TYPE_CODE === $node->type) { + return sprintf('<div class="code"><p>%s</p><pre>%s</pre></div>', $GLOBALS['TL_LANG']['MSC']['com_code'], StringUtil::specialchars($node->value, true)); + } + + if (Node::TYPE_TEXT === $node->type) { + return StringUtil::specialchars($node->value, true); + } + + throw new \RuntimeException('Invalid node type.'); + } + + private function subCompile(array $nodes): string + { + return implode('', array_map(fn (Node $node): string => $this->compile($node), $nodes)); + } +}
comments-bundle/src/Util/Node.php+78 −0 added@@ -0,0 +1,78 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of Contao. + * + * (c) Leo Feyer + * + * @license LGPL-3.0-or-later + */ + +namespace Contao\CommentsBundle\Util; + +/** + * @internal + */ +final class Node +{ + public const TYPE_ROOT = 0; + public const TYPE_TEXT = 1; + public const TYPE_BLOCK = 2; + public const TYPE_CODE = 3; + + /** + * @var Node|null + */ + public $parent; + + /** + * @var int + */ + public $type; + + /** + * @var string|null + */ + public $tag; + + /** + * @var string|null + */ + public $value; + + /** + * @var array<Node> + */ + public $children = []; + + public function __construct(self $parent = null, int $type = self::TYPE_ROOT) + { + $this->parent = $parent; + $this->type = $type; + } + + public function setTag(string $tag): self + { + $this->tag = $tag; + + return $this; + } + + public function setValue(?string $value): self + { + $this->value = $value; + + return $this; + } + + public function getFirstChildValue(): ?string + { + if (0 === \count($this->children)) { + return null; + } + + return $this->children[0]->value; + } +}
comments-bundle/tests/Util/BbCodeTest.php+140 −0 added@@ -0,0 +1,140 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of Contao. + * + * (c) Leo Feyer + * + * @license LGPL-3.0-or-later + */ + +use Contao\CommentsBundle\Util\BbCode; +use PHPUnit\Framework\TestCase; + +class BbCodeTest extends TestCase +{ + /** + * @dataProvider provideBbCode + */ + public function testConvertToHtml(string $bbCode, string $expectedHtml): void + { + $GLOBALS['TL_LANG']['MSC'] = [ + 'com_quote' => '%s wrote:', + 'com_code' => 'Code:', + ]; + + $this->assertSame($expectedHtml, (new BbCode())->toHtml($bbCode)); + + unset($GLOBALS['TL_LANG']); + } + + public function provideBbCode(): Generator + { + yield 'transforms b,i and u tags' => [ + 'This should be [b]strong,[/b] [i]italic[/i] and [u]underlined[/u].', + 'This should be <strong>strong,</strong> <em>italic</em> and <span style="text-decoration: underline">underlined</span>.', + ]; + + yield 'ignores non-opened tags' => [ + 'foo[/i] bar', + 'foo bar', + ]; + + yield 'ignores non-closed tags' => [ + 'foo [i]bar', + 'foo bar', + ]; + + yield 'ignores nesting the same tag ' => [ + '[i]foo [i]bar[/i][/i]', + '<em>foo bar</em>', + ]; + + yield 'resolves interleaved tags' => [ + '[i][b]foo[/i]bar[/b]', + '<em><strong>foo</strong></em>bar', + ]; + + yield 'transforms quote tags' => [ + '[quote]See? A "quote".[/quote]', + '<blockquote>See? A "quote".</blockquote>', + ]; + + yield 'transforms quote tags with author attribute' => [ + '[quote=Someone]I\d rather have [b]markdown[/b][/quote]', + '<blockquote><p>Someone wrote:</p>I\d rather have <strong>markdown</strong></blockquote>', + ]; + + yield 'ignores nested quotes' => [ + '[quote]A [quote]quote[/quote] of a quote[/quote]', + '<blockquote>A quote</blockquote> of a quote', + ]; + + yield 'only allows quotes on top level' => [ + 'A [b]strong [quote]statement[/quote]![/b]', + 'A <strong>strong </strong><blockquote>statement</blockquote>!', + ]; + + yield 'wraps code in pre tags' => [ + 'some [code]things without [b]formatting[/b][/code]', + 'some <div class="code"><p>Code:</p><pre>things without [b]formatting[/b]</pre></div>', + ]; + + yield 'only allows code on top level' => [ + '[i][code]no italic code?[/code][/i]', + '<div class="code"><p>Code:</p><pre>no italic code?</pre></div>', + ]; + + yield 'transforms url tags' => [ + '[url]https://example.com[/url] [url=https://example.com]my website[/url]', + '<a href="https://example.com" rel="noopener noreferrer nofollow">https://example.com</a> <a href="https://example.com" rel="noopener noreferrer nofollow">my website</a>', + ]; + + yield 'transforms email tags' => [ + '[email]foo@contao.org[/email] [email=foo@contao.org]my email address[/email]', + '<a href="mailto:foo@contao.org">foo@contao.org</a> <a href="mailto:foo@contao.org">my email address</a>', + ]; + + yield 'ignores invalid urls (no FQDN)' => [ + '[url=foobar]foobar[/url] [url]foo.org[/url]', + 'foobar foo.org', + ]; + + yield 'ignores invalid email addresses' => [ + '[email=foobar]foobar[/email] [email]foobar[/email]', + 'foobar foobar', + ]; + + yield 'ignores img and color tag' => [ + '[color="red"]colored[/color] [img]image[/img]', + 'colored image', + ]; + + yield 'does not treat other things in brackets as tags' => [ + '[x] Yes or [ ] No? [o][/o]', + '[x] Yes or [ ] No? [o][/o]', + ]; + + yield 'replaces special chars' => [ + 'a&b{{no}}]<>\'":', + 'a&b]<>'":', + ]; + + yield 'encodes malicious email' => [ + '[email]"/onmouseenter=alert(1)>"@contao.org[/email]', + '<a href="mailto:"/onmouseenter=alert(1)>"@contao.org">"/onmouseenter=alert(1)>"@contao.org</a>', + ]; + + yield 'encodes URLs' => [ + '[url]https://example.com/foo&bar[/url]', + '<a href="https://example.com/foo&bar" rel="noopener noreferrer nofollow">https://example.com/foo&bar</a>', + ]; + + yield 'encodes insert tags' => [ + '{[url]{insert_bad}[url]}', + '{{insert_bad}}', + ]; + } +}
comments-bundle/tests/Util/NodeTest.php+47 −0 added@@ -0,0 +1,47 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of Contao. + * + * (c) Leo Feyer + * + * @license LGPL-3.0-or-later + */ + +use Contao\CommentsBundle\Util\Node; +use PHPUnit\Framework\TestCase; + +class NodeTest extends TestCase +{ + public function testSetsAndGetsValues(): void + { + $default = new Node(); + + $this->assertSame(Node::TYPE_ROOT, $default->type); + $this->assertNull($default->parent); + $this->assertNull($default->tag); + $this->assertNull($default->value); + $this->assertEmpty($default->children); + + $node = (new Node($default, Node::TYPE_CODE))->setTag('tag')->setValue('value'); + + $this->assertSame($default, $node->parent); + $this->assertSame(Node::TYPE_CODE, $node->type); + $this->assertSame('tag', $node->tag); + $this->assertSame('value', $node->value); + } + + public function testGetsFirstChildValue(): void + { + $node = new Node(); + + $this->assertNull($node->getFirstChildValue()); + + $node->children[] = (new Node())->setValue('v1'); + $node->children[] = (new Node())->setValue('v2'); + + $this->assertSame('v1', $node->getFirstChildValue()); + } +}
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/advisories/GHSA-j55w-hjpj-825gghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2024-28234ghsaADVISORY
- contao.org/en/security-advisories/insufficient-bbcode-sanitizationghsax_refsource_MISCWEB
- github.com/contao/contao/commit/55b995d8d35da0d36bc6a22c53fe6423ab0c4ae2ghsax_refsource_MISCWEB
- github.com/contao/contao/commit/6d42e667177c972ae7c219645593c262d7764ce2ghsax_refsource_MISCWEB
- github.com/contao/contao/security/advisories/GHSA-j55w-hjpj-825gghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.