CVE-2023-30609: Improper Neutralization of Special Elements in Output Used by a Downstream Component ('Injection') (CWE-74)
value: matrix-react-sdk is a react-based SDK for inserting a Matrix chat/VoIP client into a web page. Prior to version 3.71.0, plain text messages containing HTML tags are rendered as HTML in the search results. To exploit this, an attacker needs to trick a user into searching for a specific message containing an HTML injection payload. No cross-site scripting attack is possible due to the hardcoded content security policy. Version 3.71.0 of the SDK patches over the issue. As a workaround, restarting the client will clear the HTML injection.
CWE description: The product constructs all or part of a command, data structure, or record using externally-influenced input from an upstream component, but it does not neutralize or incorrectly neutralizes special elements that could modify how it is parsed or interpreted when it is sent to a downstream component.
CVSS v3 base score: 4.7
Published: 2023-04-25T21:15Z
Repository: https://github.com/matrix-org/matrix-react-sdk
Language: Other
Files changed: src/HtmlUtils.tsx, src/components/views/rooms/SearchResultTile.tsx, test/HtmlUtils-test.tsx
Commit date: 2023-04-25 09:35:55 +0100
Commit message: Merge pull request from GHSA-xv83-x443-7rmw

* Escape HTML for plaintext search results

* Add tests
Diff: diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx
index f925facaf0..71f3c9e079 100644
--- a/src/HtmlUtils.tsx
+++ b/src/HtmlUtils.tsx
@@ -26,10 +26,11 @@ import { merge, split } from "lodash";
 import katex from "katex";
 import { decode } from "html-entities";
 import { IContent } from "matrix-js-sdk/src/models/event";
 import { Optional } from "matrix-events-sdk";
 import _Linkify from "linkify-react";
+import escapeHtml from "escape-html";
 
 import {
     _linkifyElement,
     _linkifyString,
     ELEMENT_URL_PATTERN,
@@ -353,23 +354,23 @@ const topicSanitizeHtmlParams: IExtendedSanitizeOptions = {
 
 abstract class BaseHighlighter<T extends React.ReactNode> {
     public constructor(public highlightClass: string, public highlightLink?: string) {}
 
     /**
-     * apply the highlights to a section of text
+     * Apply the highlights to a section of text
      *
      * @param {string} safeSnippet The snippet of text to apply the highlights
-     *     to.
+     *     to. This input must be sanitised as it will be treated as HTML.
      * @param {string[]} safeHighlights A list of substrings to highlight,
      *     sorted by descending length.
      *
      * returns a list of results (strings for HtmlHighligher, react nodes for
      * TextHighlighter).
      */
     public applyHighlights(safeSnippet: string, safeHighlights: string[]): T[] {
         let lastOffset = 0;
-        let offset;
+        let offset: number;
         let nodes: T[] = [];
 
         const safeHighlight = safeHighlights[0];
         while ((offset = safeSnippet.toLowerCase().indexOf(safeHighlight.toLowerCase(), lastOffset)) >= 0) {
             // handle preamble
@@ -438,11 +439,11 @@ interface IOpts {
     forComposerQuote?: boolean;
     ref?: React.Ref<HTMLSpanElement>;
 }
 
 export interface IOptsReturnNode extends IOpts {
-    returnString: false | undefined;
+    returnString?: false | undefined;
 }
 
 export interface IOptsReturnString extends IOpts {
     returnString: true;
 }
@@ -572,11 +573,11 @@ export function bodyToHtml(content: IContent, highlights: Optional<string[]>, op
             }
             if (bodyHasEmoji) {
                 safeBody = formatEmojis(safeBody, true).join("");
             }
         } else if (highlighter) {
-            safeBody = highlighter.applyHighlights(plainBody, safeHighlights!).join("");
+            safeBody = highlighter.applyHighlights(escapeHtml(plainBody), safeHighlights!).join("");
         }
     } finally {
         delete sanitizeParams.textFilter;
     }
 
diff --git a/src/components/views/rooms/SearchResultTile.tsx b/src/components/views/rooms/SearchResultTile.tsx
index be15ea9694..6ddb7f1067 100644
--- a/src/components/views/rooms/SearchResultTile.tsx
+++ b/src/components/views/rooms/SearchResultTile.tsx
@@ -69,11 +69,11 @@ export default class SearchResultTile extends React.Component<IProps> {
         const isTwelveHour = SettingsStore.getValue("showTwelveHourTimestamps");
         const alwaysShowTimestamps = SettingsStore.getValue("alwaysShowTimestamps");
 
         for (let j = 0; j < timeline.length; j++) {
             const mxEv = timeline[j];
-            let highlights;
+            let highlights: string[] | undefined;
             const contextual = !this.props.ourEventsIndexes.includes(j);
             if (!contextual) {
                 highlights = this.props.searchHighlights;
             }
 
diff --git a/test/HtmlUtils-test.tsx b/test/HtmlUtils-test.tsx
index 8baeea446a..d63470e122 100644
--- a/test/HtmlUtils-test.tsx
+++ b/test/HtmlUtils-test.tsx
@@ -12,26 +12,27 @@ distributed under the License is distributed on an "AS IS" BASIS,
 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-import React from "react";
+import React, { ReactElement } from "react";
 import { mocked } from "jest-mock";
 import { render, screen } from "@testing-library/react";
+import { IContent } from "matrix-js-sdk/src/models/event";
 
-import { topicToHtml } from "../src/HtmlUtils";
+import { bodyToHtml, topicToHtml } from "../src/HtmlUtils";
 import SettingsStore from "../src/settings/SettingsStore";
 
 jest.mock("../src/settings/SettingsStore");
 
 const enableHtmlTopicFeature = () => {
     mocked(SettingsStore).getValue.mockImplementation((arg): any => {
         return arg === "feature_html_topic";
     });
 };
 
-describe("HtmlUtils", () => {
+describe("topicToHtml", () => {
     function getContent() {
         return screen.getByRole("contentinfo").children[0].innerHTML;
     }
 
     it("converts plain text topic to HTML", () => {
@@ -60,5 +61,49 @@ describe("HtmlUtils", () => {
         enableHtmlTopicFeature();
         render(<div role="contentinfo">{topicToHtml("**pizza** 🍕", "<b>pizza</b> 🍕", null, false)}</div>);
         expect(getContent()).toEqual('<b>pizza</b> <span class="mx_Emoji" title=":pizza:">🍕</span>');
     });
 });
+
+describe("bodyToHtml", () => {
+    function getHtml(content: IContent, highlights?: string[]): string {
+        return (bodyToHtml(content, highlights, {}) as ReactElement).props.dangerouslySetInnerHTML.__html;
+    }
+
+    it("should apply highlights to HTML messages", () => {
+        const html = getHtml(
+            {
+                body: "test **foo** bar",
+                msgtype: "m.text",
+                formatted_body: "test <b>foo</b> bar",
+                format: "org.matrix.custom.html",
+            },
+            ["test"],
+        );
+
+        expect(html).toMatchInlineSnapshot(`"<span class="mx_EventTile_searchHighlight">test</span> <b>foo</b> bar"`);
+    });
+
+    it("should apply highlights to plaintext messages", () => {
+        const html = getHtml(
+            {
+                body: "test foo bar",
+                msgtype: "m.text",
+            },
+            ["test"],
+        );
+
+        expect(html).toMatchInlineSnapshot(`"<span class="mx_EventTile_searchHighlight">test</span> foo bar"`);
+    });
+
+    it("should not respect HTML tags in plaintext message highlighting", () => {
+        const html = getHtml(
+            {
+                body: "test foo <b>bar",
+                msgtype: "m.text",
+            },
+            ["test"],
+        );
+
+        expect(html).toMatchInlineSnapshot(`"<span class="mx_EventTile_searchHighlight">test</span> foo &lt;b&gt;bar"`);
+    });
+});

Vulnerable code:      * apply the highlights to a section of text
     *     to.
        let offset;
    returnString: false | undefined;
            safeBody = highlighter.applyHighlights(plainBody, safeHighlights!).join("");
            let highlights;
import React from "react";
import { topicToHtml } from "../src/HtmlUtils";
describe("HtmlUtils", () => {
Fixed code: import escapeHtml from "escape-html";
     * Apply the highlights to a section of text
     *     to. This input must be sanitised as it will be treated as HTML.
        let offset: number;
    returnString?: false | undefined;
            safeBody = highlighter.applyHighlights(escapeHtml(plainBody), safeHighlights!).join("");
            let highlights: string[] | undefined;
import React, { ReactElement } from "react";
import { IContent } from "matrix-js-sdk/src/models/event";
import { bodyToHtml, topicToHtml } from "../src/HtmlUtils";
describe("topicToHtml", () => {

describe("bodyToHtml", () => {
    function getHtml(content: IContent, highlights?: string[]): string {
        return (bodyToHtml(content, highlights, {}) as ReactElement).props.dangerouslySetInnerHTML.__html;
    }

    it("should apply highlights to HTML messages", () => {
        const html = getHtml(
            {
                body: "test **foo** bar",
                msgtype: "m.text",
                formatted_body: "test <b>foo</b> bar",
                format: "org.matrix.custom.html",
            },
            ["test"],
        );

        expect(html).toMatchInlineSnapshot(`"<span class="mx_EventTile_searchHighlight">test</span> <b>foo</b> bar"`);
    });

    it("should apply highlights to plaintext messages", () => {
        const html = getHtml(
            {
                body: "test foo bar",
                msgtype: "m.text",
            },
            ["test"],
        );

        expect(html).toMatchInlineSnapshot(`"<span class="mx_EventTile_searchHighlight">test</span> foo bar"`);
    });

    it("should not respect HTML tags in plaintext message highlighting", () => {
        const html = getHtml(
            {
                body: "test foo <b>bar",
                msgtype: "m.text",
            },
            ["test"],
        );

        expect(html).toMatchInlineSnapshot(`"<span class="mx_EventTile_searchHighlight">test</span> foo &lt;b&gt;bar"`);
    });
});