CVE-2023-43657: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') (CWE-79)
value: discourse-encrypt is a plugin that provides a secure communication channel through Discourse. Improper escaping of encrypted topic titles could lead to a cross site scripting (XSS) issue when a site has content security policy (CSP) headers disabled. Having CSP disabled is a non-default configuration, and having it disabled with discourse-encrypt installed will result in a warning in the Discourse admin dashboard. This has been fixed in commit `9c75810af9` which is included in the latest version of the discourse-encrypt plugin. Users are advised to upgrade. Users unable to upgrade should ensure that CSP headers are enabled and properly configured.
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: 6.1
Published: 2023-09-28T19:15Z
Repository: https://github.com/discourse/discourse-encrypt
Language: JavaScript
Files changed: .discourse-compatibility, assets/javascripts/discourse/initializers/decrypt-topics.js, test/javascripts/acceptance/encrypt-test.js
Commit date: 2023-09-28 13:50:39 +0100
Commit message: SECURITY: Properly escape encrypted topic titles (#283)


Diff: diff --git a/.discourse-compatibility b/.discourse-compatibility
index 13e4ba0..41ecac1 100644
--- a/.discourse-compatibility
+++ b/.discourse-compatibility
@@ -1,5 +1,4 @@
-3.1.999: 05ca875fb543350795f6709ab7afcbb6e8f5871b
 3.1.0.beta5: e0eb21f7a97c54cfe40eb07b2c5ca53cde7b370a
 3.1.0.beta3: 0f3c612bd1d274ba71362c423f16fd19a32bbd46
 2.9.0.beta3: adef8c76aaeccaeb081a61bdd8ce7ab90ca41538
 2.9.0.beta2: dc2b844e386351c778b097c7a4e0ec65726296ec
diff --git a/assets/javascripts/discourse/initializers/decrypt-topics.js b/assets/javascripts/discourse/initializers/decrypt-topics.js
index 6f7f9f7..ed2624d 100644
--- a/assets/javascripts/discourse/initializers/decrypt-topics.js
+++ b/assets/javascripts/discourse/initializers/decrypt-topics.js
@@ -311,11 +311,11 @@ export default {
       document.querySelector(".private_message").classList.add("encrypted");
     }
 
     getTopicTitle(topicId).then((topicTitle) => {
       // Update fancy title stored in model
-      topicController.model.set("fancy_title", topicTitle);
+      topicController.model.set("fancy_title", escapeExpression(topicTitle));
 
       // Update document title
       const documentTitle = this.container.lookup("service:document-title");
       documentTitle.setTitle(
         documentTitle
diff --git a/test/javascripts/acceptance/encrypt-test.js b/test/javascripts/acceptance/encrypt-test.js
index dbcd2b2..1c151dd 100644
--- a/test/javascripts/acceptance/encrypt-test.js
+++ b/test/javascripts/acceptance/encrypt-test.js
@@ -205,15 +205,18 @@ function setupEncryptTests(needs) {
       return helper.response({});
     });
   });
 }
 
-async function setupEncryptedTopicPretender(server, { identity } = {}) {
+async function setupEncryptedTopicPretender(
+  server,
+  { identity, topicTitle = "Top Secret Title" } = {}
+) {
   identity = identity || (await getIdentity());
   const topicKey = await generateKey();
   const exportedTopicKey = await exportKey(topicKey, identity.encryptPublic);
-  const encryptedTitle = await encrypt(topicKey, { raw: "Top Secret Title" });
+  const encryptedTitle = await encrypt(topicKey, { raw: topicTitle });
   const encryptedRaw = await encrypt(topicKey, { raw: "Top Secret Post" });
 
   server.get("/t/42.json", () => {
     return [
       200,
@@ -842,10 +845,24 @@ acceptance("Encrypt - active", function (needs) {
     await click(".private_message.encrypted h1[data-topic-id] .edit-topic");
 
     assert.strictEqual(query("#edit-title").value.trim(), "Top Secret Title");
   });
 
+  test("viewing encrypted topic escapes title correctly", async function (assert) {
+    globalAssert = assert;
+
+    const title = "Title <a>with some html chars</a>";
+    await setupEncryptedTopicPretender(pretender, { topicTitle: title });
+
+    await visit("/t/a-secret-message/42");
+
+    assert
+      .dom(".fancy-title")
+      .hasText(title, "Title in UI is escaped correctly");
+    assert.strictEqual(document.title, `${title} - QUnit Discourse Tests`);
+  });
+
   test("topic titles in notification panel are decrypted", async function (assert) {
     const identity = await getIdentity();
     const topicKey = await generateKey();
     const exportedKey = await exportKey(topicKey, identity.encryptPublic);
     const title = "Top Secret :male_detective:";

Vulnerable code: 3.1.999: 05ca875fb543350795f6709ab7afcbb6e8f5871b
      topicController.model.set("fancy_title", topicTitle);
async function setupEncryptedTopicPretender(server, { identity } = {}) {
  const encryptedTitle = await encrypt(topicKey, { raw: "Top Secret Title" });
Fixed code:       topicController.model.set("fancy_title", escapeExpression(topicTitle));
async function setupEncryptedTopicPretender(
  server,
  { identity, topicTitle = "Top Secret Title" } = {}
) {
  const encryptedTitle = await encrypt(topicKey, { raw: topicTitle });
  test("viewing encrypted topic escapes title correctly", async function (assert) {
    globalAssert = assert;

    const title = "Title <a>with some html chars</a>";
    await setupEncryptedTopicPretender(pretender, { topicTitle: title });

    await visit("/t/a-secret-message/42");

    assert
      .dom(".fancy-title")
      .hasText(title, "Title in UI is escaped correctly");
    assert.strictEqual(document.title, `${title} - QUnit Discourse Tests`);
  });