CVE-2023-30619: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') (CWE-79)
value: Tuleap Open ALM is a Libre and Open Source tool for end to end traceability of application and system developments. The title of an artifact is not properly escaped in the tooltip. A malicious user with the capability to create an artifact or to edit a field title could force victim to execute uncontrolled code. This issue has been patched in version 14.7.99.143.

CWE description: The product does not neutralize or incorrectly neutralizes user-controllable input before it is placed in output that is used as a web page that is served to other users.
CVSS v3 base score: 5.4
Published: 2023-05-04T14:15Z
Repository: https://github.com/Enalean/tuleap
Language: PHP
Files changed: plugins/tracker/include/Tracker/Artifact/Artifact.php, plugins/tracker/include/Tracker/Semantic/Tooltip/TooltipFetcher.php, plugins/tracker/templates/tooltip/artifact-tooltip-title.mustache, plugins/tracker/tests/unit/Tracker/Semantic/Tooltip/TooltipFetcherTest.php
Commit date: 2023-04-20 18:16:19 +0200
Commit message: Fix request #31586: XSS in the tooltip via an artifact title

Next step for the current story #26777 is to display the xref on top of
the title so in order to prepare the field we use mustache to escape the
title instead of DOMPurifier.

Part of story #26777: have artifact tooltips on roadmap

Change-Id: I534ead8a88361b364f5ee81556251dc3dc4c0bf6

Diff: diff --git a/plugins/tracker/include/Tracker/Artifact/Artifact.php b/plugins/tracker/include/Tracker/Artifact/Artifact.php
index 111d51e306b2..4e4d5c46bd1e 100644
--- a/plugins/tracker/include/Tracker/Artifact/Artifact.php
+++ b/plugins/tracker/include/Tracker/Artifact/Artifact.php
@@ -454,10 +454,11 @@ class Artifact implements Recent_Element_Interface, Tracker_Dispatchable_Interfa
      */
     public function fetchTooltip(PFUser $user): Tuleap\Option\Option
     {
         $progress_dao    = new SemanticProgressDao();
         $tooltip_fetcher = new Tuleap\Tracker\Semantic\Tooltip\TooltipFetcher(
+            TemplateRendererFactory::build(),
             new Tuleap\Tracker\Semantic\Tooltip\OtherSemantic\ProgressTooltipEntry(
                 new SemanticProgressBuilder(
                     $progress_dao,
                     new MethodBuilder(
                         $this->getFormElementFactory(),
diff --git a/plugins/tracker/include/Tracker/Semantic/Tooltip/TooltipFetcher.php b/plugins/tracker/include/Tracker/Semantic/Tooltip/TooltipFetcher.php
index 81c65e54f12e..bf3885f50350 100644
--- a/plugins/tracker/include/Tracker/Semantic/Tooltip/TooltipFetcher.php
+++ b/plugins/tracker/include/Tracker/Semantic/Tooltip/TooltipFetcher.php
@@ -31,12 +31,14 @@ final class TooltipFetcher
     /**
      * @var OtherSemanticTooltipEntryFetcher[]
      */
     private readonly array $other_semantic_tooltip_entry_fetchers;
 
-    public function __construct(OtherSemanticTooltipEntryFetcher ...$other_semantic_tooltip_entry_fetchers)
-    {
+    public function __construct(
+        private readonly \TemplateRendererFactory $renderer_factory,
+        OtherSemanticTooltipEntryFetcher ...$other_semantic_tooltip_entry_fetchers,
+    ) {
         $this->other_semantic_tooltip_entry_fetchers = $other_semantic_tooltip_entry_fetchers;
     }
 
     /**
      * @return Option<TooltipJSON>
@@ -56,12 +58,16 @@ final class TooltipFetcher
             $html .= $field->fetchTooltip($artifact);
         }
         $html .= '</table>';
 
         return Option::fromValue(
-            TooltipJSON::fromHtmlTitleAndHtmlBody((string) $artifact->getTitle(), $html)
-                ->withAccentColor($artifact->getTracker()->getColor()->getName())
+            TooltipJSON::fromHtmlTitleAndHtmlBody(
+                $this->renderer_factory
+                    ->getRenderer(__DIR__ . '/../../../../templates/tooltip/')
+                    ->renderToString('artifact-tooltip-title', ['title' => $artifact->getTitle()]),
+                $html
+            )->withAccentColor($artifact->getTracker()->getColor()->getName())
         );
     }
 
     /**
      * @return \Tracker_FormElement_Field[]
diff --git a/plugins/tracker/templates/tooltip/artifact-tooltip-title.mustache b/plugins/tracker/templates/tooltip/artifact-tooltip-title.mustache
new file mode 100644
index 000000000000..6d5a89f8a7f4
--- /dev/null
+++ b/plugins/tracker/templates/tooltip/artifact-tooltip-title.mustache
@@ -0,0 +1 @@
+{{ title }}
diff --git a/plugins/tracker/tests/unit/Tracker/Semantic/Tooltip/TooltipFetcherTest.php b/plugins/tracker/tests/unit/Tracker/Semantic/Tooltip/TooltipFetcherTest.php
index d0ec5ee58740..050d628ee870 100644
--- a/plugins/tracker/tests/unit/Tracker/Semantic/Tooltip/TooltipFetcherTest.php
+++ b/plugins/tracker/tests/unit/Tracker/Semantic/Tooltip/TooltipFetcherTest.php
@@ -20,10 +20,12 @@
 
 declare(strict_types=1);
 
 namespace Tuleap\Tracker\Semantic\Tooltip;
 
+use TemplateRendererFactory;
+use Tuleap\Templating\TemplateCache;
 use Tuleap\Test\Builders\UserTestBuilder;
 use Tuleap\Test\PHPUnit\TestCase;
 use Tuleap\Tracker\Artifact\Artifact;
 use Tuleap\Tracker\Test\Builders\TrackerTestBuilder;
 use Tuleap\Tracker\Test\Stub\Semantic\Tooltip\TooltipFieldsStub;
@@ -40,12 +42,16 @@ final class TooltipFetcherTest extends TestCase
             $this->createStub(\Tracker_FormElement_Field::class),
         );
 
         $user = UserTestBuilder::buildWithDefaults();
 
+        $template_cache = $this->createMock(TemplateCache::class);
+        $template_cache->method('getPath')->willReturn(null);
+        $template_factory = new TemplateRendererFactory($template_cache);
+
         self::assertTrue(
-            (new TooltipFetcher())
+            (new TooltipFetcher($template_factory))
                 ->fetchArtifactTooltip($artifact, $tooltip, $user)
                 ->isNothing()
         );
     }
 
@@ -56,12 +62,16 @@ final class TooltipFetcherTest extends TestCase
 
         $tooltip = TooltipFieldsStub::withoutFields();
 
         $user = UserTestBuilder::buildWithDefaults();
 
+        $template_cache = $this->createMock(TemplateCache::class);
+        $template_cache->method('getPath')->willReturn(null);
+        $template_factory = new TemplateRendererFactory($template_cache);
+
         self::assertTrue(
-            (new TooltipFetcher())
+            (new TooltipFetcher($template_factory))
                 ->fetchArtifactTooltip($artifact, $tooltip, $user)
                 ->isNothing()
         );
     }
 
@@ -89,12 +99,16 @@ final class TooltipFetcherTest extends TestCase
             $field_2,
         );
 
         $user = UserTestBuilder::buildWithDefaults();
 
-        $tooltip = (new TooltipFetcher())->fetchArtifactTooltip($artifact, $tooltip, $user);
-        self::assertEquals('The title', $tooltip->unwrapOr('')->title_as_html);
+        $template_cache = $this->createMock(TemplateCache::class);
+        $template_cache->method('getPath')->willReturn(null);
+        $template_factory = new TemplateRendererFactory($template_cache);
+
+        $tooltip = (new TooltipFetcher($template_factory))->fetchArtifactTooltip($artifact, $tooltip, $user);
+        self::assertStringContainsString('The title', $tooltip->unwrapOr('')->title_as_html);
         self::assertStringContainsString('avada', $tooltip->unwrapOr('')->body_as_html);
         self::assertStringContainsString('kedavra', $tooltip->unwrapOr('')->body_as_html);
         self::assertEquals('fiesta-red', $tooltip->unwrapOr('')->accent_color);
     }
 
@@ -122,11 +136,16 @@ final class TooltipFetcherTest extends TestCase
             $field_2,
         );
 
         $user = UserTestBuilder::buildWithDefaults();
 
+        $template_cache = $this->createMock(TemplateCache::class);
+        $template_cache->method('getPath')->willReturn(null);
+        $template_factory = new TemplateRendererFactory($template_cache);
+
         $tooltip = (new TooltipFetcher(
+            $template_factory,
             new class implements OtherSemanticTooltipEntryFetcher {
                 public function fetchTooltipEntry(Artifact $artifact, \PFUser $user): string
                 {
                     return 'Susan';
                 }
@@ -136,11 +155,11 @@ final class TooltipFetcherTest extends TestCase
                 {
                     return 'Dennis';
                 }
             },
         ))->fetchArtifactTooltip($artifact, $tooltip, $user);
-        self::assertEquals('The title', $tooltip->unwrapOr('')->title_as_html);
+        self::assertStringContainsString('The title', $tooltip->unwrapOr('')->title_as_html);
         self::assertStringContainsString('Susan', $tooltip->unwrapOr('')->body_as_html);
         self::assertStringContainsString('Dennis', $tooltip->unwrapOr('')->body_as_html);
         self::assertStringContainsString('avada', $tooltip->unwrapOr('')->body_as_html);
         self::assertStringContainsString('kedavra', $tooltip->unwrapOr('')->body_as_html);
         self::assertEquals('fiesta-red', $tooltip->unwrapOr('')->accent_color);
@@ -166,11 +185,15 @@ final class TooltipFetcherTest extends TestCase
             $field_2,
         );
 
         $user = UserTestBuilder::buildWithDefaults();
 
-        $tooltip = (new TooltipFetcher())->fetchArtifactTooltip($artifact, $tooltip, $user);
-        self::assertEquals('', $tooltip->unwrapOr('')->title_as_html);
+        $template_cache = $this->createMock(TemplateCache::class);
+        $template_cache->method('getPath')->willReturn(null);
+        $template_factory = new TemplateRendererFactory($template_cache);
+
+        $tooltip = (new TooltipFetcher($template_factory))->fetchArtifactTooltip($artifact, $tooltip, $user);
+        self::assertEquals('', trim($tooltip->unwrapOr('')->title_as_html));
         self::assertStringContainsString('avada', $tooltip->unwrapOr('')->body_as_html);
         self::assertStringNotContainsString('kedavra', $tooltip->unwrapOr('')->body_as_html);
     }
 }

Vulnerable code:     public function __construct(OtherSemanticTooltipEntryFetcher ...$other_semantic_tooltip_entry_fetchers)
    {
            TooltipJSON::fromHtmlTitleAndHtmlBody((string) $artifact->getTitle(), $html)
                ->withAccentColor($artifact->getTracker()->getColor()->getName())
            (new TooltipFetcher())
            (new TooltipFetcher())
        $tooltip = (new TooltipFetcher())->fetchArtifactTooltip($artifact, $tooltip, $user);
        self::assertEquals('The title', $tooltip->unwrapOr('')->title_as_html);
        self::assertEquals('The title', $tooltip->unwrapOr('')->title_as_html);
        $tooltip = (new TooltipFetcher())->fetchArtifactTooltip($artifact, $tooltip, $user);
        self::assertEquals('', $tooltip->unwrapOr('')->title_as_html);
Fixed code:             TemplateRendererFactory::build(),
    public function __construct(
        private readonly \TemplateRendererFactory $renderer_factory,
        OtherSemanticTooltipEntryFetcher ...$other_semantic_tooltip_entry_fetchers,
    ) {
            TooltipJSON::fromHtmlTitleAndHtmlBody(
                $this->renderer_factory
                    ->getRenderer(__DIR__ . '/../../../../templates/tooltip/')
                    ->renderToString('artifact-tooltip-title', ['title' => $artifact->getTitle()]),
                $html
            )->withAccentColor($artifact->getTracker()->getColor()->getName())
{{ title }}
use TemplateRendererFactory;
use Tuleap\Templating\TemplateCache;
        $template_cache = $this->createMock(TemplateCache::class);
        $template_cache->method('getPath')->willReturn(null);
        $template_factory = new TemplateRendererFactory($template_cache);

            (new TooltipFetcher($template_factory))
        $template_cache = $this->createMock(TemplateCache::class);
        $template_cache->method('getPath')->willReturn(null);
        $template_factory = new TemplateRendererFactory($template_cache);

            (new TooltipFetcher($template_factory))
        $template_cache = $this->createMock(TemplateCache::class);
        $template_cache->method('getPath')->willReturn(null);
        $template_factory = new TemplateRendererFactory($template_cache);

        $tooltip = (new TooltipFetcher($template_factory))->fetchArtifactTooltip($artifact, $tooltip, $user);
        self::assertStringContainsString('The title', $tooltip->unwrapOr('')->title_as_html);
        $template_cache = $this->createMock(TemplateCache::class);
        $template_cache->method('getPath')->willReturn(null);
        $template_factory = new TemplateRendererFactory($template_cache);

            $template_factory,
        self::assertStringContainsString('The title', $tooltip->unwrapOr('')->title_as_html);
        $template_cache = $this->createMock(TemplateCache::class);
        $template_cache->method('getPath')->willReturn(null);
        $template_factory = new TemplateRendererFactory($template_cache);

        $tooltip = (new TooltipFetcher($template_factory))->fetchArtifactTooltip($artifact, $tooltip, $user);
        self::assertEquals('', trim($tooltip->unwrapOr('')->title_as_html));