CVE-2024-28234 (published 2024-04-09T14:15Z)

Contao is an open source content management system. Starting in version 2.0.0 and prior to versions 4.13.40 and 5.3.4, it is possible to inject CSS styles via BBCode in comments. Installations are only affected if BBCode is enabled. Contao versions 4.13.40 and 5.3.4 have a patch for this issue. As a workaround, disable BBCode for comments.


Weakness information:
cwe_id: NVD-CWE-noinfo
cwe_name: Insufficient Information
description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
extended_description: Insufficient Information

Fix information:
repo_url: https://github.com/contao/contao
commit_details: repo_url: https://github.com/contao/contao
author: Leo Feyer
author_date: 2024-04-09 07:18:05+02:00
msg: Merge pull request from GHSA-j55w-hjpj-825g

* harden BBCode parsing

* Apply suggestions from code review

Co-authored-by: Martin Auswöger <martin@auswoeger.com>

* CS

* Update comments-bundle/tests/Util/BbCodeTest.php

Co-authored-by: Martin Auswöger <martin@auswoeger.com>

* Update comments-bundle/src/Util/BbCode.php

Co-authored-by: Martin Auswöger <martin@auswoeger.com>

* Update comments-bundle/tests/Util/BbCodeTest.php

Co-authored-by: Martin Auswöger <martin@auswoeger.com>

* Update comments-bundle/tests/Util/BbCodeTest.php

* CS

* Run the tools

---------

Co-authored-by: M. Vondano <m-vo@users.noreply.github.com>
Co-authored-by: Martin Auswöger <martin@auswoeger.com>
repository: repo_url: https://github.com/contao/contao
repo_name: contao/contao
description: Contao Open Source CMS
repo_language: PHP
owner: contao
file_changes: filename: Comments.php
change_type: ModificationType.MODIFY
diff: @@ -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 @@ class Comments extends Frontend
 	/**
 	 * 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);
 	}
 
 	/**

programming_language: PHP
filename: BbCode.php
change_type: ModificationType.ADD
diff: @@ -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));
+    }
+}

programming_language: PHP
filename: Node.php
change_type: ModificationType.ADD
diff: @@ -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;
+    }
+}

programming_language: PHP
filename: BbCodeTest.php
change_type: ModificationType.ADD
diff: @@ -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;',
+        ];
+    }
+}

programming_language: PHP
filename: NodeTest.php
change_type: ModificationType.ADD
diff: @@ -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());
+    }
+}

programming_language: PHP
repo_url: https://github.com/contao/contao
commit_details: repo_url: https://github.com/contao/contao
author: M. Vondano
author_date: 2024-04-09 07:18:05+02:00
msg: Merge pull request from GHSA-j55w-hjpj-825g

* harden BBCode parsing

* Apply suggestions from code review

Co-authored-by: Martin Auswöger <martin@auswoeger.com>

* CS

* Update comments-bundle/tests/Util/BbCodeTest.php

Co-authored-by: Martin Auswöger <martin@auswoeger.com>

* Update comments-bundle/src/Util/BbCode.php

Co-authored-by: Martin Auswöger <martin@auswoeger.com>

* Update comments-bundle/tests/Util/BbCodeTest.php

Co-authored-by: Martin Auswöger <martin@auswoeger.com>

* Update comments-bundle/tests/Util/BbCodeTest.php

* CS

---------

Co-authored-by: Martin Auswöger <martin@auswoeger.com>
Co-authored-by: Leo Feyer <1192057+leofeyer@users.noreply.github.com>
repository: repo_url: https://github.com/contao/contao
repo_name: contao/contao
description: Contao Open Source CMS
repo_language: PHP
owner: contao
file_changes: filename: Comments.php
change_type: ModificationType.MODIFY
diff: @@ -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 @@ class Comments extends Frontend
 	/**
 	 * 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);
 	}
 
 	/**

programming_language: PHP
filename: BbCode.php
change_type: ModificationType.ADD
diff: @@ -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));
+    }
+}

programming_language: PHP
filename: Node.php
change_type: ModificationType.ADD
diff: @@ -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;
+    }
+}

programming_language: PHP
filename: BbCodeTest.php
change_type: ModificationType.ADD
diff: @@ -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;',
+        ];
+    }
+}

programming_language: PHP
filename: NodeTest.php
change_type: ModificationType.ADD
diff: @@ -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());
+    }
+}

programming_language: PHP