CVE-2023-37259: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') (CWE-79)
value: matrix-react-sdk is a react-based SDK for inserting a Matrix chat/voip client into a web page. The Export Chat feature includes certain attacker-controlled elements in the generated document without sufficient escaping, leading to stored Cross site scripting (XSS). Since the Export Chat feature generates a separate document, an attacker can only inject code run from the `null` origin, restricting the impact. However, the attacker can still potentially use the XSS to leak message contents. A malicious homeserver is a potential attacker since the affected inputs are controllable server-side. This issue has been addressed in commit `22fcd34c60` which is included in release version 3.76.0. Users are advised to upgrade. The only known workaround for this issue is to disable or to not use the Export Chat feature.
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-07-18T17:15Z
Repository: https://github.com/matrix-org/matrix-react-sdk
Language: Other
Files changed: src/utils/exportUtils/HtmlExport.tsx, test/utils/exportUtils/HTMLExport-test.ts
Commit date: 2023-07-18 13:23:27 +0100
Commit message: Sanitise strings going into the html export CVE-2023-37259

Diff: diff --git a/src/utils/exportUtils/HtmlExport.tsx b/src/utils/exportUtils/HtmlExport.tsx
index 667978b7b0..41edfd93df 100644
--- a/src/utils/exportUtils/HtmlExport.tsx
+++ b/src/utils/exportUtils/HtmlExport.tsx
@@ -19,10 +19,11 @@ import ReactDOM from "react-dom";
 import { Room } from "matrix-js-sdk/src/models/room";
 import { MatrixEvent } from "matrix-js-sdk/src/models/event";
 import { renderToStaticMarkup } from "react-dom/server";
 import { EventType, MsgType } from "matrix-js-sdk/src/@types/event";
 import { logger } from "matrix-js-sdk/src/logger";
+import escapeHtml from "escape-html";
 
 import Exporter from "./Exporter";
 import { mediaFromMxc } from "../../customisations/Media";
 import { Layout } from "../../settings/enums/Layout";
 import { shouldFormContinuation } from "../../components/structures/MessagePanel";
@@ -95,41 +96,49 @@ export default class HTMLExporter extends Exporter {
         const creator = this.room.currentState.getStateEvents(EventType.RoomCreate, "")?.getSender();
         const creatorName = (creator ? this.room.getMember(creator)?.rawDisplayName : creator) || creator;
         const exporter = this.room.client.getSafeUserId();
         const exporterName = this.room.getMember(exporter)?.rawDisplayName;
         const topic = this.room.currentState.getStateEvents(EventType.RoomTopic, "")?.getContent()?.topic || "";
-        const createdText = _t("%(creatorName)s created this room.", {
-            creatorName,
-        });
 
-        const exportedText = renderToStaticMarkup(
+        const safeCreatedText = escapeHtml(
+            _t("%(creatorName)s created this room.", {
+                creatorName,
+            }),
+        );
+        const safeExporter = escapeHtml(exporter);
+        const safeRoomName = escapeHtml(this.room.name);
+        const safeTopic = escapeHtml(topic);
+        const safeExportedText = renderToStaticMarkup(
             <p>
                 {_t(
                     "This is the start of export of <roomName/>. Exported by <exporterDetails/> at %(exportDate)s.",
                     {
                         exportDate,
                     },
                     {
-                        roomName: () => <b>{this.room.name}</b>,
+                        roomName: () => <b>{safeRoomName}</b>,
                         exporterDetails: () => (
-                            <a href={`https://matrix.to/#/${exporter}`} target="_blank" rel="noopener noreferrer">
+                            <a
+                                href={`https://matrix.to/#/${encodeURIComponent(exporter)}`}
+                                target="_blank"
+                                rel="noopener noreferrer"
+                            >
                                 {exporterName ? (
                                     <>
-                                        <b>{exporterName}</b>
-                                        {" (" + exporter + ")"}
+                                        <b>{escapeHtml(exporterName)}</b>I {" (" + safeExporter + ")"}
                                     </>
                                 ) : (
-                                    <b>{exporter}</b>
+                                    <b>{safeExporter}</b>
                                 )}
                             </a>
                         ),
                     },
                 )}
             </p>,
         );
 
-        const topicText = topic ? _t("Topic: %(topic)s", { topic }) : "";
+        const safeTopicText = topic ? _t("Topic: %(topic)s", { topic: safeTopic }) : "";
         const previousMessagesLink = renderToStaticMarkup(
             currentPage !== 0 ? (
                 <div style={{ textAlign: "center" }}>
                     <a href={`./messages${currentPage === 1 ? "" : currentPage}.html`} style={{ fontWeight: "bold" }}>
                         {_t("Previous group of messages")}
@@ -181,16 +190,16 @@ export default class HTMLExporter extends Exporter {
                             </div>
                             <div class="mx_RoomHeader_name">
                             <div
                                 dir="auto"
                                 class="mx_RoomHeader_nametext"
-                                title="${this.room.name}"
+                                title="${safeRoomName}"
                             >
-                                ${this.room.name}
+                                ${safeRoomName}
                             </div>
                             </div>
-                            <div class="mx_RoomHeader_topic" dir="auto"> ${topic} </div>
+                            <div class="mx_RoomHeader_topic" dir="auto"> ${safeTopic} </div>
                         </div>
                         </div>
                         ${previousMessagesLink}
                         <div class="mx_MainSplit">
                         <div class="mx_RoomView_body">
@@ -212,14 +221,14 @@ export default class HTMLExporter extends Exporter {
                                 >
                                 ${
                                     currentPage == 0
                                         ? `<div class="mx_NewRoomIntro">
                                         ${roomAvatar}
-                                        <h2> ${this.room.name} </h2>
-                                        <p> ${createdText} <br/><br/> ${exportedText} </p>
+                                        <h2> ${safeRoomName} </h2>
+                                        <p> ${safeCreatedText} <br/><br/> ${safeExportedText} </p>
                                         <br/>
-                                        <p> ${topicText} </p>
+                                        <p> ${safeTopicText} </p>
                                     </div>`
                                         : ""
                                 }
                                 ${content}
                                 </ol>
diff --git a/test/utils/exportUtils/HTMLExport-test.ts b/test/utils/exportUtils/HTMLExport-test.ts
index f81764170c..53512dbad1 100644
--- a/test/utils/exportUtils/HTMLExport-test.ts
+++ b/test/utils/exportUtils/HTMLExport-test.ts
@@ -23,10 +23,11 @@ import {
     Room,
     RoomMember,
     RoomState,
 } from "matrix-js-sdk/src/matrix";
 import fetchMock from "fetch-mock-jest";
+import escapeHtml from "escape-html";
 
 import { filterConsole, mkStubRoom, REPEATABLE_DATE, stubClient } from "../../test-utils";
 import { ExportType, IExportOptions } from "../../../src/utils/exportUtils/exportUtils";
 import SdkConfig from "../../../src/SdkConfig";
 import HTMLExporter from "../../../src/utils/exportUtils/HtmlExport";
@@ -503,6 +504,51 @@ describe("HTMLExport", () => {
         expect(result).toContain(
             '<div style="text-align:center"><a href="./messages2.html" style="font-weight:bold">Previous group of messages</a></div>',
         );
         expect(result).not.toContain("Next group of messages");
     });
+
+    it("should not leak javascript from room names or topics", async () => {
+        const name = "<svg onload=alert(3)>";
+        const topic = "<svg onload=alert(5)>";
+        mockMessages(EVENT_MESSAGE);
+        room.currentState.setStateEvents([
+            new MatrixEvent({
+                type: EventType.RoomName,
+                event_id: "$00001",
+                room_id: room.roomId,
+                sender: "@alice:example.com",
+                origin_server_ts: 0,
+                content: { name },
+                state_key: "",
+            }),
+            new MatrixEvent({
+                type: EventType.RoomTopic,
+                event_id: "$00002",
+                room_id: room.roomId,
+                sender: "@alice:example.com",
+                origin_server_ts: 1,
+                content: { topic },
+                state_key: "",
+            }),
+        ]);
+        room.recalculate();
+
+        const exporter = new HTMLExporter(
+            room,
+            ExportType.Timeline,
+            {
+                attachmentsIncluded: false,
+                maxSize: 1_024 * 1_024,
+            },
+            () => {},
+        );
+
+        await exporter.export();
+        const html = await getMessageFile(exporter).text();
+
+        expect(html).not.toContain(`${name}`);
+        expect(html).toContain(`${escapeHtml(name)}`);
+        expect(html).not.toContain(`${topic}`);
+        expect(html).toContain(`Topic: ${escapeHtml(topic)}`);
+    });
 });

Vulnerable code:         const createdText = _t("%(creatorName)s created this room.", {
            creatorName,
        });
        const exportedText = renderToStaticMarkup(
                        roomName: () => <b>{this.room.name}</b>,
                            <a href={`https://matrix.to/#/${exporter}`} target="_blank" rel="noopener noreferrer">
                                        <b>{exporterName}</b>
                                        {" (" + exporter + ")"}
                                    <b>{exporter}</b>
        const topicText = topic ? _t("Topic: %(topic)s", { topic }) : "";
                                title="${this.room.name}"
                                ${this.room.name}
                            <div class="mx_RoomHeader_topic" dir="auto"> ${topic} </div>
                                        <h2> ${this.room.name} </h2>
                                        <p> ${createdText} <br/><br/> ${exportedText} </p>
                                        <p> ${topicText} </p>
Fixed code: import escapeHtml from "escape-html";
        const safeCreatedText = escapeHtml(
            _t("%(creatorName)s created this room.", {
                creatorName,
            }),
        );
        const safeExporter = escapeHtml(exporter);
        const safeRoomName = escapeHtml(this.room.name);
        const safeTopic = escapeHtml(topic);
        const safeExportedText = renderToStaticMarkup(
                        roomName: () => <b>{safeRoomName}</b>,
                            <a
                                href={`https://matrix.to/#/${encodeURIComponent(exporter)}`}
                                target="_blank"
                                rel="noopener noreferrer"
                            >
                                        <b>{escapeHtml(exporterName)}</b>I {" (" + safeExporter + ")"}
                                    <b>{safeExporter}</b>
        const safeTopicText = topic ? _t("Topic: %(topic)s", { topic: safeTopic }) : "";
                                title="${safeRoomName}"
                                ${safeRoomName}
                            <div class="mx_RoomHeader_topic" dir="auto"> ${safeTopic} </div>
                                        <h2> ${safeRoomName} </h2>
                                        <p> ${safeCreatedText} <br/><br/> ${safeExportedText} </p>
                                        <p> ${safeTopicText} </p>
import escapeHtml from "escape-html";

    it("should not leak javascript from room names or topics", async () => {
        const name = "<svg onload=alert(3)>";
        const topic = "<svg onload=alert(5)>";
        mockMessages(EVENT_MESSAGE);
        room.currentState.setStateEvents([
            new MatrixEvent({
                type: EventType.RoomName,
                event_id: "$00001",
                room_id: room.roomId,
                sender: "@alice:example.com",
                origin_server_ts: 0,
                content: { name },
                state_key: "",
            }),
            new MatrixEvent({
                type: EventType.RoomTopic,
                event_id: "$00002",
                room_id: room.roomId,
                sender: "@alice:example.com",
                origin_server_ts: 1,
                content: { topic },
                state_key: "",
            }),
        ]);
        room.recalculate();

        const exporter = new HTMLExporter(
            room,
            ExportType.Timeline,
            {
                attachmentsIncluded: false,
                maxSize: 1_024 * 1_024,
            },
            () => {},
        );

        await exporter.export();
        const html = await getMessageFile(exporter).text();

        expect(html).not.toContain(`${name}`);
        expect(html).toContain(`${escapeHtml(name)}`);
        expect(html).not.toContain(`${topic}`);
        expect(html).toContain(`Topic: ${escapeHtml(topic)}`);
    });