CVE-2024-28234 (PHP, published 2024-04-09T14:15Z)
Description: 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.
Severity: nan (CVSS v3 base score: )
Weakness: NVD-CWE-noinfo \u2014 Insufficient Information
There is insufficient information about the issue to classify it; details are unkown or unspecified.
Repository: https://github.com/contao/contao
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 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>

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());
    }
}