CVE-2024-28234: Insufficient Information (NVD-CWE-noinfo)
value: 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.
CWE description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
Published: 2024-04-09T14:15Z
Repository: https://github.com/contao/contao
Language: PHP
Files changed: comments-bundle/contao/classes/Comments.php, comments-bundle/src/Util/BbCode.php, comments-bundle/src/Util/Node.php, comments-bundle/tests/Util/BbCodeTest.php, comments-bundle/tests/Util/NodeTest.php
Commit date: 2024-04-09 07:18:05 +0200
Commit message: 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>
Diff: diff --git a/comments-bundle/contao/classes/Comments.php b/comments-bundle/contao/classes/Comments.php
index 358fb91f23e..cb326fc2e64 100644
--- a/comments-bundle/contao/classes/Comments.php
+++ b/comments-bundle/contao/classes/Comments.php
@@ -8,10 +8,11 @@
  * @license LGPL-3.0-or-later
  */
 
 namespace Contao;
 
+use Contao\CommentsBundle\Util\BbCode;
 use Contao\CoreBundle\EventListener\Widget\HttpUrlListener;
 use Contao\CoreBundle\Exception\PageNotFoundException;
 use Contao\CoreBundle\Util\UrlUtil;
 use Nyholm\Psr7\Uri;
 
@@ -406,74 +407,17 @@ 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);
 	}
 
 	/**
 	 * Convert line feeds to <br /> tags
 	 *
diff --git a/comments-bundle/src/Util/BbCode.php b/comments-bundle/src/Util/BbCode.php
new file mode 100644
index 00000000000..f129541119b
--- /dev/null
+++ b/comments-bundle/src/Util/BbCode.php
@@ -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));
+    }
+}
diff --git a/comments-bundle/src/Util/Node.php b/comments-bundle/src/Util/Node.php
new file mode 100644
index 00000000000..db9bdf53b5a
--- /dev/null
+++ b/comments-bundle/src/Util/Node.php
@@ -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;
+    }
+}
diff --git a/comments-bundle/tests/Util/BbCodeTest.php b/comments-bundle/tests/Util/BbCodeTest.php
new file mode 100644
index 00000000000..2bcf545c21a
--- /dev/null
+++ b/comments-bundle/tests/Util/BbCodeTest.php
@@ -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;',
+        ];
+    }
+}
diff --git a/comments-bundle/tests/Util/NodeTest.php b/comments-bundle/tests/Util/NodeTest.php
new file mode 100644
index 00000000000..6bc25811c7f
--- /dev/null
+++ b/comments-bundle/tests/Util/NodeTest.php
@@ -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());
+    }
+}

Vulnerable code: 	 * 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]
	 *
		$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;
Fixed code: use Contao\CommentsBundle\Util\BbCode;
		return (new BbCode())->toHtml($strComment);
<?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));
    }
}
<?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;
    }
}
<?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;',
        ];
    }
}
<?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());
    }
}