CVE-2022-39270: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') (CWE-79)
value: DiscoTOC is a Discourse theme component that generates a table of contents for topics. Users that can create topics in TOC-enabled categories (and have sufficient trust level - configured in component's settings) are able to inject arbitrary HTML on that topic's page. The issue has been fixed on the `main` branch. Admins can update the theme component through the admin UI (Customize -> Themes -> Components -> DiscoTOC -> Check for Updates). Alternatively, admins can temporarily disable the DiscoTOC theme component.
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: 2022-10-06T18:16Z
Repository: https://github.com/discourse/DiscoTOC
Language: JSON
Files changed: about.json, javascripts/discourse/initializers/disco-toc-main.js, package.json, test/acceptance/toc-test.js
Commit date: 2022-10-03 21:10:05 +0200
Commit message: SECURITY: Render TOC items as plain text (#44)


Diff: diff --git a/about.json b/about.json
index 5c5090e..7cfcea2 100644
--- a/about.json
+++ b/about.json
@@ -1,9 +1,10 @@
 {
   "name": "DiscoTOC",
   "component": true,
   "about_url": "https://meta.discourse.org/t/discotoc-automatic-table-of-contents/111143",
   "license_url": "https://github.com/discourse/DiscoTOC/blob/main/LICENSE",
+  "theme_version": "2.1.0",
   "assets": {
     "icons-sprite": "/assets/sprite.svg"
   }
 }
diff --git a/javascripts/discourse/initializers/disco-toc-main.js b/javascripts/discourse/initializers/disco-toc-main.js
index 4765b71..13fd7ce 100644
--- a/javascripts/discourse/initializers/disco-toc-main.js
+++ b/javascripts/discourse/initializers/disco-toc-main.js
@@ -284,13 +284,13 @@ export default {
     clonedNode.querySelector("span.clicks")?.remove();
     const li = document.createElement("li");
     li.classList.add("d-toc-item");
     li.classList.add(`d-toc-${clonedNode.tagName.toLowerCase()}`);
 
-    li.innerHTML = `<a href="#" data-d-toc="${clonedNode.getAttribute("id")}">${
-      clonedNode.textContent
-    }</a>`;
+    const id = clonedNode.getAttribute("id");
+    li.innerHTML = `<a href="#" data-d-toc="${id}"></a>`;
+    li.querySelector("a").innerText = clonedNode.textContent.trim();
 
     clonedNode.remove();
     return li;
   },
 };
diff --git a/package.json b/package.json
index 421ab82..17f48ec 100644
--- a/package.json
+++ b/package.json
@@ -1,8 +1,8 @@
 {
   "name": "DiscoTOC",
-  "version": "2.0.0",
+  "version": "2.1.0",
   "repository": "https://github.com/discourse/DiscoTOC",
   "author": "Discourse",
   "license": "MIT",
   "devDependencies": {
     "eslint-config-discourse": "^3.2.0"
diff --git a/test/acceptance/toc-test.js b/test/acceptance/toc-test.js
index be8e69a..0b38e69 100644
--- a/test/acceptance/toc-test.js
+++ b/test/acceptance/toc-test.js
@@ -120,5 +120,33 @@ acceptance("DiscoTOC - with categories", function (needs) {
   test("automaticly adds TOC based on category", async function (assert) {
     await visit("/t/internationalization-localization/280");
     assert.ok(exists(".d-toc-wrapper #d-toc"));
   });
 });
+
+acceptance("DiscoTOC - non-text headings", function (needs) {
+  needs.pretender((server, helper) => {
+    const topicResponse = cloneJSON(topicFixtures["/t/280/1.json"]);
+    topicResponse.post_stream.posts[0].cooked = `
+      <h3 id="toc-h3-span" data-d-toc="toc-h3-span" class="d-toc-post-heading">
+        <a name="span-4" class="anchor" href="#span-4"></a>&lt;span style="color: red"&gt;what about this&lt;/span&gt;</h3>
+      </h3>
+      <p>test</p>
+      ${TOC_MARKUP}
+    `;
+
+    server.get("/t/280.json", () => helper.response(topicResponse));
+    server.get("/t/280/:post_number.json", () =>
+      helper.response(topicResponse)
+    );
+  });
+
+  test("renders the TOC items as plain text", async function (assert) {
+    await visit("/t/internationalization-localization/280");
+
+    const item = query(`#d-toc [data-d-toc="toc-h3-span"]`);
+    assert.strictEqual(
+      item.innerHTML.trim(),
+      `&lt;span style="color: red"&gt;what about this&lt;/span&gt;`
+    );
+  });
+});

Vulnerable code:     li.innerHTML = `<a href="#" data-d-toc="${clonedNode.getAttribute("id")}">${
      clonedNode.textContent
    }</a>`;
  "version": "2.0.0",
Fixed code:   "theme_version": "2.1.0",
    const id = clonedNode.getAttribute("id");
    li.innerHTML = `<a href="#" data-d-toc="${id}"></a>`;
    li.querySelector("a").innerText = clonedNode.textContent.trim();
  "version": "2.1.0",

acceptance("DiscoTOC - non-text headings", function (needs) {
  needs.pretender((server, helper) => {
    const topicResponse = cloneJSON(topicFixtures["/t/280/1.json"]);
    topicResponse.post_stream.posts[0].cooked = `
      <h3 id="toc-h3-span" data-d-toc="toc-h3-span" class="d-toc-post-heading">
        <a name="span-4" class="anchor" href="#span-4"></a>&lt;span style="color: red"&gt;what about this&lt;/span&gt;</h3>
      </h3>
      <p>test</p>
      ${TOC_MARKUP}
    `;

    server.get("/t/280.json", () => helper.response(topicResponse));
    server.get("/t/280/:post_number.json", () =>
      helper.response(topicResponse)
    );
  });

  test("renders the TOC items as plain text", async function (assert) {
    await visit("/t/internationalization-localization/280");

    const item = query(`#d-toc [data-d-toc="toc-h3-span"]`);
    assert.strictEqual(
      item.innerHTML.trim(),
      `&lt;span style="color: red"&gt;what about this&lt;/span&gt;`
    );
  });
});