VYPR
Moderate severityNVD Advisory· Published Apr 9, 2024· Updated Aug 2, 2024

Contao has insufficient BBCode sanitizer

CVE-2024-28234

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.

PackageAffected versionsPatched versions
contao/comments-bundlePackagist
>= 2.0.0, < 4.13.404.13.40
contao/comments-bundlePackagist
>= 5.0.0-RC1, < 5.3.45.3.4

Affected products

2

Patches

2
55b995d8d35d

Merge pull request from GHSA-j55w-hjpj-825g

https://github.com/contao/contaoLeo FeyerApr 9, 2024via ghsa
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(['{', '}'], ['&#123;', '&#125;'], $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 &quot;quote&quot;.</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&amp;b]&lt;&gt;&apos;&quot;:',
    +        ];
    +
    +        yield 'encodes malicious email' => [
    +            '[email]"/onmouseenter=alert(1)>"@contao.org[/email]',
    +            '<a href="mailto:&quot;/onmouseenter=alert(1)&gt;&quot;@contao.org">&quot;/onmouseenter=alert(1)&gt;&quot;@contao.org</a>',
    +        ];
    +
    +        yield 'encodes URLs' => [
    +            '[url]https://example.com/foo&bar[/url]',
    +            '<a href="https://example.com/foo&amp;bar" rel="noopener noreferrer nofollow">https://example.com/foo&amp;bar</a>',
    +        ];
    +
    +        yield 'encodes insert tags' => [
    +            '{[url]{insert_bad}[url]}',
    +            '&#123;&#123;insert_bad&#125;&#125;',
    +        ];
    +    }
    +}
    
  • 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());
    +    }
    +}
    
6d42e667177c

Merge pull request from GHSA-j55w-hjpj-825g

https://github.com/contao/contaoM. VondanoApr 9, 2024via ghsa
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(['{', '}'], ['&#123;', '&#125;'], $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 &quot;quote&quot;.</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&amp;b]&lt;&gt;&apos;&quot;:',
    +        ];
    +
    +        yield 'encodes malicious email' => [
    +            '[email]"/onmouseenter=alert(1)>"@contao.org[/email]',
    +            '<a href="mailto:&quot;/onmouseenter=alert(1)&gt;&quot;@contao.org">&quot;/onmouseenter=alert(1)&gt;&quot;@contao.org</a>',
    +        ];
    +
    +        yield 'encodes URLs' => [
    +            '[url]https://example.com/foo&bar[/url]',
    +            '<a href="https://example.com/foo&amp;bar" rel="noopener noreferrer nofollow">https://example.com/foo&amp;bar</a>',
    +        ];
    +
    +        yield 'encodes insert tags' => [
    +            '{[url]{insert_bad}[url]}',
    +            '&#123;&#123;insert_bad&#125;&#125;',
    +        ];
    +    }
    +}
    
  • 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

News mentions

0

No linked articles in our index yet.