CVE-2022-39279: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') (CWE-79)
value: discourse-chat is a plugin for the Discourse message board which adds chat functionality. In versions prior to 0.9 some places render a chat channel's name and description in an unsafe way, allowing staff members to cause an cross site scripting (XSS) attack by inserting unsafe HTML into them. Version 0.9 has addressed this issue. Users are advised to upgrade. There are no known workarounds for this issue.
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-06T20:15Z
Repository: https://github.com/discourse/discourse-chat
Language: JavaScript
Files changed: assets/javascripts/discourse/components/chat-channel-archive-modal-inner.js, assets/javascripts/discourse/components/chat-channel-delete-modal-inner.js, assets/javascripts/discourse/components/chat-message-move-to-channel-modal-inner.js, assets/javascripts/discourse/initializers/chat-sidebar.js, assets/javascripts/discourse/models/chat-channel.js, assets/javascripts/discourse/templates/components/chat-channel-about-view.hbs, assets/javascripts/discourse/templates/components/chat-channel-card.hbs, assets/javascripts/discourse/templates/components/chat-channel-title.hbs
Commit date: 2022-10-05 15:44:52 -0300
Commit message: SECURITY: Ensure channel name and description are always escaped. (#1280)


Diff: diff --git a/assets/javascripts/discourse/components/chat-channel-archive-modal-inner.js b/assets/javascripts/discourse/components/chat-channel-archive-modal-inner.js
index a826dbd5..7928e804 100644
--- a/assets/javascripts/discourse/components/chat-channel-archive-modal-inner.js
+++ b/assets/javascripts/discourse/components/chat-channel-archive-modal-inner.js
@@ -12,11 +12,10 @@ import {
   EXISTING_TOPIC_SELECTION,
   NEW_TOPIC_SELECTION,
 } from "discourse/plugins/discourse-chat/discourse/components/chat-to-topic-selector";
 import { CHANNEL_STATUSES } from "discourse/plugins/discourse-chat/discourse/models/chat-channel";
 import { htmlSafe } from "@ember/template";
-import { escapeExpression } from "discourse/lib/utilities";
 
 export default Component.extend({
   chat: service(),
   tagName: "",
   chatChannel: null,
@@ -106,10 +105,10 @@ export default Component.extend({
 
   @discourseComputed()
   instructionsText() {
     return htmlSafe(
       I18n.t("chat.channel_archive.instructions", {
-        channelTitle: escapeExpression(this.chatChannel.title),
+        channelTitle: this.chatChannel.escapedTitle,
       })
     );
   },
 });
diff --git a/assets/javascripts/discourse/components/chat-channel-delete-modal-inner.js b/assets/javascripts/discourse/components/chat-channel-delete-modal-inner.js
index 362a71a5..3f38523f 100644
--- a/assets/javascripts/discourse/components/chat-channel-delete-modal-inner.js
+++ b/assets/javascripts/discourse/components/chat-channel-delete-modal-inner.js
@@ -6,11 +6,10 @@ import { action } from "@ember/object";
 import { ajax } from "discourse/lib/ajax";
 import { inject as service } from "@ember/service";
 import { popupAjaxError } from "discourse/lib/ajax-error";
 import discourseLater from "discourse-common/lib/later";
 import { htmlSafe } from "@ember/template";
-import { escapeExpression } from "discourse/lib/utilities";
 
 export default Component.extend({
   chat: service(),
   router: service(),
   tagName: "",
@@ -25,12 +24,12 @@ export default Component.extend({
       return true;
     }
 
     if (
       isEmpty(channelNameConfirmation) ||
-      escapeExpression(channelNameConfirmation).toLowerCase() !==
-        this.escapedTitle.toLowerCase()
+      channelNameConfirmation.toLowerCase() !==
+        this.chatChannel.title.toLowerCase()
     ) {
       return true;
     }
     return false;
   },
@@ -60,15 +59,10 @@ export default Component.extend({
 
   @discourseComputed()
   instructionsText() {
     return htmlSafe(
       I18n.t("chat.channel_delete.instructions", {
-        name: this.escapedTitle,
+        name: this.chatChannel.escapedTitle,
       })
     );
   },
-
-  @discourseComputed()
-  escapedTitle() {
-    return escapeExpression(this.chatChannel.title);
-  },
 });
diff --git a/assets/javascripts/discourse/components/chat-message-move-to-channel-modal-inner.js b/assets/javascripts/discourse/components/chat-message-move-to-channel-modal-inner.js
index 72cff457..3045b7f4 100644
--- a/assets/javascripts/discourse/components/chat-message-move-to-channel-modal-inner.js
+++ b/assets/javascripts/discourse/components/chat-message-move-to-channel-modal-inner.js
@@ -5,11 +5,10 @@ import { isBlank } from "@ember/utils";
 import { action, computed } from "@ember/object";
 import { ajax } from "discourse/lib/ajax";
 import { inject as service } from "@ember/service";
 import { popupAjaxError } from "discourse/lib/ajax-error";
 import { htmlSafe } from "@ember/template";
-import { escapeExpression } from "discourse/lib/utilities";
 
 export default class MoveToChannelModalInner extends Component {
   @service chat;
   @service router;
   tagName = "";
@@ -56,11 +55,11 @@ export default class MoveToChannelModalInner extends Component {
 
   @computed()
   get instructionsText() {
     return htmlSafe(
       I18n.t("chat.move_to_channel.instructions", {
-        channelTitle: escapeExpression(this.sourceChannel.title),
+        channelTitle: this.sourceChannel.escapedTitle,
         count: this.selectedMessageCount,
       })
     );
   }
 }
diff --git a/assets/javascripts/discourse/initializers/chat-sidebar.js b/assets/javascripts/discourse/initializers/chat-sidebar.js
index 7b6f30c2..82f1c0f2 100644
--- a/assets/javascripts/discourse/initializers/chat-sidebar.js
+++ b/assets/javascripts/discourse/initializers/chat-sidebar.js
@@ -76,11 +76,11 @@ export default {
               get models() {
                 return [this.channel.id, slugifyChannel(this.title)];
               }
 
               get title() {
-                return escapeExpression(this.channel.title);
+                return this.channel.escapedTitle;
               }
 
               get text() {
                 return htmlSafe(emojiUnescape(this.title));
               }
@@ -240,11 +240,11 @@ export default {
             get models() {
               return [this.channel.id, slugifyChannel(this.title)];
             }
 
             get title() {
-              return escapeExpression(this.channel.title);
+              return this.channel.escapedTitle;
             }
 
             get oneOnOneMessage() {
               return this.channel.chatable.users.length === 1;
             }
diff --git a/assets/javascripts/discourse/models/chat-channel.js b/assets/javascripts/discourse/models/chat-channel.js
index e413a2d7..46c0728a 100644
--- a/assets/javascripts/discourse/models/chat-channel.js
+++ b/assets/javascripts/discourse/models/chat-channel.js
@@ -2,10 +2,11 @@ import RestModel from "discourse/models/rest";
 import I18n from "I18n";
 import { computed } from "@ember/object";
 import User from "discourse/models/user";
 import UserChatChannelMembership from "discourse/plugins/discourse-chat/discourse/models/user-chat-channel-membership";
 import { ajax } from "discourse/lib/ajax";
+import { escapeExpression } from "discourse/lib/utilities";
 
 export const CHATABLE_TYPES = {
   directMessageChannel: "DirectMessageChannel",
   categoryChannel: "Category",
 };
@@ -60,10 +61,20 @@ const READONLY_STATUSES = [
 
 export default class ChatChannel extends RestModel {
   isDraft = false;
   lastSendReadMessageId = null;
 
+  @computed("title")
+  get escapedTitle() {
+    return escapeExpression(this.title);
+  }
+
+  @computed("description")
+  get escapedDescription() {
+    return escapeExpression(this.description);
+  }
+
   @computed("chatable_type")
   get isDirectMessageChannel() {
     return this.chatable_type === CHATABLE_TYPES.directMessageChannel;
   }
 
diff --git a/assets/javascripts/discourse/templates/components/chat-channel-about-view.hbs b/assets/javascripts/discourse/templates/components/chat-channel-about-view.hbs
index 0b4bdf28..c6cdd705 100644
--- a/assets/javascripts/discourse/templates/components/chat-channel-about-view.hbs
+++ b/assets/javascripts/discourse/templates/components/chat-channel-about-view.hbs
@@ -25,11 +25,11 @@
         </div>
       {{/if}}
     </label>
     <div class="chat-form__control">
       <div class="channel-info-about-view__title">
-        {{replace-emoji channel.title}}
+        {{replace-emoji channel.escapedTitle}}
       </div>
     </div>
   </div>
 </div>
 
diff --git a/assets/javascripts/discourse/templates/components/chat-channel-card.hbs b/assets/javascripts/discourse/templates/components/chat-channel-card.hbs
index 4ee6c99e..25248052 100644
--- a/assets/javascripts/discourse/templates/components/chat-channel-card.hbs
+++ b/assets/javascripts/discourse/templates/components/chat-channel-card.hbs
@@ -22,20 +22,20 @@
       channel.id
       (slugify-channel channel.title)
       class="chat-channel-card__name-container"
     }}
       <span class="chat-channel-card__name">
-        {{replace-emoji channel.title}}
+        {{replace-emoji channel.escapedTitle}}
       </span>
       {{#if channel.chatable.read_restricted}}
         {{d-icon "lock" class="chat-channel-card__read-restricted"}}
       {{/if}}
     {{/link-to}}
 
     {{#if channel.description}}
       <div class="chat-channel-card__description">
-        {{replace-emoji channel.description}}
+        {{replace-emoji channel.escapedDescription}}
       </div>
     {{/if}}
 
     <div class="chat-channel-card__cta">
       {{#if channel.isFollowing}}
diff --git a/assets/javascripts/discourse/templates/components/chat-channel-title.hbs b/assets/javascripts/discourse/templates/components/chat-channel-title.hbs
index 9442aa35..d8245e4a 100644
--- a/assets/javascripts/discourse/templates/components/chat-channel-title.hbs
+++ b/assets/javascripts/discourse/templates/components/chat-channel-title.hbs
@@ -42,11 +42,11 @@
         {{#if channel.chatable.read_restricted}}
           {{d-icon "lock" class="chat-channel-title__restricted-category-icon"}}
         {{/if}}
       </span>
       <span class="chat-channel-title__name">
-        {{replace-emoji channel.title}}
+        {{replace-emoji channel.escapedTitle}}
       </span>
 
       {{#if (has-block)}}
         {{yield}}
       {{/if}}

Vulnerable code: import { escapeExpression } from "discourse/lib/utilities";
        channelTitle: escapeExpression(this.chatChannel.title),
import { escapeExpression } from "discourse/lib/utilities";
      escapeExpression(channelNameConfirmation).toLowerCase() !==
        this.escapedTitle.toLowerCase()
        name: this.escapedTitle,

  @discourseComputed()
  escapedTitle() {
    return escapeExpression(this.chatChannel.title);
  },
import { escapeExpression } from "discourse/lib/utilities";
        channelTitle: escapeExpression(this.sourceChannel.title),
                return escapeExpression(this.channel.title);
              return escapeExpression(this.channel.title);
        {{replace-emoji channel.title}}
        {{replace-emoji channel.title}}
        {{replace-emoji channel.description}}
        {{replace-emoji channel.title}}
Fixed code:         channelTitle: this.chatChannel.escapedTitle,
      channelNameConfirmation.toLowerCase() !==
        this.chatChannel.title.toLowerCase()
        name: this.chatChannel.escapedTitle,
        channelTitle: this.sourceChannel.escapedTitle,
                return this.channel.escapedTitle;
              return this.channel.escapedTitle;
import { escapeExpression } from "discourse/lib/utilities";
  @computed("title")
  get escapedTitle() {
    return escapeExpression(this.title);
  }

  @computed("description")
  get escapedDescription() {
    return escapeExpression(this.description);
  }

        {{replace-emoji channel.escapedTitle}}
        {{replace-emoji channel.escapedTitle}}
        {{replace-emoji channel.escapedDescription}}
        {{replace-emoji channel.escapedTitle}}