CVE-2022-31059: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') (CWE-79)
value: Discourse Calendar is a calendar plugin for Discourse, an open-source messaging app. Prior to version 1.0.1, parsing and rendering of Event names can be susceptible to cross-site scripting (XSS) attacks. This vulnerability only affects sites which have modified or disabled Discourse’s default Content Security Policy. This issue is patched in version 1.0.1 of the Discourse Calendar plugin. As a workaround, ensure that the Content Security Policy is enabled, and has not been modified in a way which would make it more vulnerable to XSS attacks.
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-06-14T20:15Z
Repository: https://github.com/discourse/discourse-calendar
Language: JavaScript
Files changed: assets/javascripts/discourse/widgets/discourse-post-event.js, assets/javascripts/discourse/widgets/event-invitation-notification-item.js, assets/javascripts/discourse/widgets/event-reminder-notification-item.js, db/post_migrate/20220613073844_unescape_event_name.rb, lib/discourse_post_event/event_parser.rb, spec/lib/discourse_post_event/event_parser_spec.rb
Commit date: 2022-06-13 11:13:56 -0600
Commit message: SECURITY: correctly escape event name (#280)


Diff: diff --git a/assets/javascripts/discourse/widgets/discourse-post-event.js b/assets/javascripts/discourse/widgets/discourse-post-event.js
index 2d31786..0a45842 100644
--- a/assets/javascripts/discourse/widgets/discourse-post-event.js
+++ b/assets/javascripts/discourse/widgets/discourse-post-event.js
@@ -8,10 +8,11 @@ import showModal from "discourse/lib/show-modal";
 import hbs from "discourse/widgets/hbs-compiler";
 import { createWidget } from "discourse/widgets/widget";
 import { routeAction } from "discourse/helpers/route-action";
 import { buildParams, replaceRaw } from "../../lib/raw-event-helper";
 import bootbox from "bootbox";
+import { escapeExpression } from "discourse/lib/utilities";
 
 export default createWidget("discourse-post-event", {
   tagName: "div.discourse-post-event-widget",
 
   buildKey: (attrs) => `discourse-post-event-${attrs.id}`,
@@ -158,11 +159,11 @@ export default createWidget("discourse-post-event", {
         `discourse_post_event.models.event.status.${eventModel.status}.description`
       ),
       startsAtMonth: moment(eventModel.starts_at).format("MMM"),
       startsAtDay: moment(eventModel.starts_at).format("D"),
       eventName: emojiUnescape(
-        eventModel.name ||
+        escapeExpression(eventModel.name) ||
           this._cleanTopicTitle(
             eventModel.post.topic.title,
             eventModel.starts_at
           )
       ),
@@ -255,10 +256,11 @@ export default createWidget("discourse-post-event", {
       {{/if}}
     {{/if}}
   `,
 
   _cleanTopicTitle(topicTitle, startsAt) {
+    topicTitle = escapeExpression(topicTitle);
     const cleaned = cleanTitle(topicTitle, startsAt);
     if (cleaned) {
       return topicTitle.replace(cleaned, "");
     }
 
diff --git a/assets/javascripts/discourse/widgets/event-invitation-notification-item.js b/assets/javascripts/discourse/widgets/event-invitation-notification-item.js
index e23f389..5806588 100644
--- a/assets/javascripts/discourse/widgets/event-invitation-notification-item.js
+++ b/assets/javascripts/discourse/widgets/event-invitation-notification-item.js
@@ -1,9 +1,9 @@
 import I18n from "I18n";
 import { createWidgetFrom } from "discourse/widgets/widget";
 import { DefaultNotificationItem } from "discourse/widgets/default-notification-item";
-import { formatUsername } from "discourse/lib/utilities";
+import { escapeExpression, formatUsername } from "discourse/lib/utilities";
 import { iconNode } from "discourse-common/lib/icon-library";
 
 createWidgetFrom(
   DefaultNotificationItem,
   "event-invitation-notification-item",
@@ -14,11 +14,13 @@ createWidgetFrom(
 
     text(notificationName, data) {
       const username = `<span>${formatUsername(data.display_username)}</span>`;
       let description;
       if (data.topic_title) {
-        description = `<span data-topic-id="${this.attrs.topic_id}">${data.topic_title}</span>`;
+        description = `<span data-topic-id="${
+          this.attrs.topic_id
+        }">${escapeExpression(data.topic_title)}</span>`;
       } else {
         description = this.description(data);
       }
 
       return I18n.t(data.message, { description, username });
diff --git a/assets/javascripts/discourse/widgets/event-reminder-notification-item.js b/assets/javascripts/discourse/widgets/event-reminder-notification-item.js
index 7e785ab..7fd8b0a 100644
--- a/assets/javascripts/discourse/widgets/event-reminder-notification-item.js
+++ b/assets/javascripts/discourse/widgets/event-reminder-notification-item.js
@@ -1,9 +1,9 @@
 import I18n from "I18n";
 import { createWidgetFrom } from "discourse/widgets/widget";
 import { DefaultNotificationItem } from "discourse/widgets/default-notification-item";
-import { formatUsername } from "discourse/lib/utilities";
+import { escapeExpression, formatUsername } from "discourse/lib/utilities";
 import { iconNode } from "discourse-common/lib/icon-library";
 
 createWidgetFrom(DefaultNotificationItem, "event-reminder-notification-item", {
   notificationTitle(notificationName, data) {
     return data.title ? I18n.t(data.title) : "";
@@ -12,11 +12,13 @@ createWidgetFrom(DefaultNotificationItem, "event-reminder-notification-item", {
   text(notificationName, data) {
     const username = formatUsername(data.display_username);
 
     let description;
     if (data.topic_title) {
-      description = `<span data-topic-id="${this.attrs.topic_id}">${data.topic_title}</span>`;
+      description = `<span data-topic-id="${
+        this.attrs.topic_id
+      }">${escapeExpression(data.topic_title)}</span>`;
     } else {
       description = this.description(data);
     }
 
     return I18n.t(data.message, { description, username });
diff --git a/db/post_migrate/20220613073844_unescape_event_name.rb b/db/post_migrate/20220613073844_unescape_event_name.rb
new file mode 100644
index 0000000..0bf77bc
--- /dev/null
+++ b/db/post_migrate/20220613073844_unescape_event_name.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+class UnescapeEventName < ActiveRecord::Migration[7.0]
+  disable_ddl_transaction!
+
+  def up
+    # event notifications
+    start = 1
+    limit = DB.query_single("SELECT MAX(id) FROM notifications WHERE notification_type IN (27, 28)").first.to_i
+
+    notifications_query = <<~SQL
+      SELECT id, data
+      FROM notifications
+      WHERE
+        id >= :start AND
+        notification_type IN (27, 28) AND
+        data::json ->> 'topic_title' LIKE '%&%'
+      ORDER BY id ASC
+      LIMIT 1000
+    SQL
+    while true
+      if start > limit
+        break
+      end
+      max_seen = -1
+      DB.query(notifications_query, start: start).each do |record|
+        id = record.id
+        if id > max_seen
+          max_seen = id
+        end
+        data = JSON.parse(record.data)
+        unescaped = CGI.unescapeHTML(data["topic_title"])
+        next if unescaped == data["topic_title"]
+        data["topic_title"] = unescaped
+        DB.exec(<<~SQL, data: data.to_json, id: id)
+          UPDATE notifications SET data = :data WHERE id = :id
+        SQL
+      end
+      start += 1000
+      if max_seen > start
+        start = max_seen + 1
+      end
+    end
+
+    # event names
+    events_query = <<~SQL
+      SELECT id, name
+      FROM discourse_post_event_events
+      WHERE name LIKE '%&%'
+      ORDER BY id ASC
+    SQL
+
+    DB.query(events_query).each do |event|
+      unescaped_name = CGI.unescapeHTML(event.name)
+      next if unescaped_name == event.name
+      DB.exec(<<~SQL, unescaped_name: unescaped_name, id: event.id)
+        UPDATE discourse_post_event_events SET name = :unescaped_name WHERE id = :id
+      SQL
+    end
+  end
+
+  def down
+    raise ActiveRecord::IrreversibleMigration
+  end
+end
diff --git a/lib/discourse_post_event/event_parser.rb b/lib/discourse_post_event/event_parser.rb
index e1d10ff..9e65e07 100644
--- a/lib/discourse_post_event/event_parser.rb
+++ b/lib/discourse_post_event/event_parser.rb
@@ -32,11 +32,15 @@ module DiscoursePostEvent
           name = attribute.name
           value = attribute.value
 
           if value && valid_options.include?(name)
             event ||= {}
-            event[name.sub('data-', '').to_sym] = CGI.escapeHTML(value)
+            event[name.sub('data-', '').to_sym] = if name == "data-name"
+              value
+            else
+              CGI.escapeHTML(value)
+            end
           end
 
           valid_custom_fields.each do |valid_custom_field|
             if value && valid_custom_field[:normalized] == name
               event ||= {}
diff --git a/spec/lib/discourse_post_event/event_parser_spec.rb b/spec/lib/discourse_post_event/event_parser_spec.rb
index 12c3854..18a83a8 100644
--- a/spec/lib/discourse_post_event/event_parser_spec.rb
+++ b/spec/lib/discourse_post_event/event_parser_spec.rb
@@ -78,10 +78,15 @@ describe DiscoursePostEvent::EventParser do
 
     events = subject.extract_events(post_event)
     expect(events).to eq([])
   end
 
+  it 'doesn’t escape event name' do
+    events = subject.extract_events(build_post(user, '[event start="foo" name="bar <script> baz"]\n[/event]'))
+    expect(events[0][:name]).to eq("bar <script> baz")
+  end
+
   context 'with custom fields' do
     before do
       SiteSetting.discourse_post_event_allowed_custom_fields = 'foo-bar|bar'
     end
 

Vulnerable code:         eventModel.name ||
import { formatUsername } from "discourse/lib/utilities";
        description = `<span data-topic-id="${this.attrs.topic_id}">${data.topic_title}</span>`;
import { formatUsername } from "discourse/lib/utilities";
      description = `<span data-topic-id="${this.attrs.topic_id}">${data.topic_title}</span>`;
            event[name.sub('data-', '').to_sym] = CGI.escapeHTML(value)
Fixed code: import { escapeExpression } from "discourse/lib/utilities";
        escapeExpression(eventModel.name) ||
    topicTitle = escapeExpression(topicTitle);
import { escapeExpression, formatUsername } from "discourse/lib/utilities";
        description = `<span data-topic-id="${
          this.attrs.topic_id
        }">${escapeExpression(data.topic_title)}</span>`;
import { escapeExpression, formatUsername } from "discourse/lib/utilities";
      description = `<span data-topic-id="${
        this.attrs.topic_id
      }">${escapeExpression(data.topic_title)}</span>`;
# frozen_string_literal: true

class UnescapeEventName < ActiveRecord::Migration[7.0]
  disable_ddl_transaction!

  def up
    # event notifications
    start = 1
    limit = DB.query_single("SELECT MAX(id) FROM notifications WHERE notification_type IN (27, 28)").first.to_i

    notifications_query = <<~SQL
      SELECT id, data
      FROM notifications
      WHERE
        id >= :start AND
        notification_type IN (27, 28) AND
        data::json ->> 'topic_title' LIKE '%&%'
      ORDER BY id ASC
      LIMIT 1000
    SQL
    while true
      if start > limit
        break
      end
      max_seen = -1
      DB.query(notifications_query, start: start).each do |record|
        id = record.id
        if id > max_seen
          max_seen = id
        end
        data = JSON.parse(record.data)
        unescaped = CGI.unescapeHTML(data["topic_title"])
        next if unescaped == data["topic_title"]
        data["topic_title"] = unescaped
        DB.exec(<<~SQL, data: data.to_json, id: id)
          UPDATE notifications SET data = :data WHERE id = :id
        SQL
      end
      start += 1000
      if max_seen > start
        start = max_seen + 1
      end
    end

    # event names
    events_query = <<~SQL
      SELECT id, name
      FROM discourse_post_event_events
      WHERE name LIKE '%&%'
      ORDER BY id ASC
    SQL

    DB.query(events_query).each do |event|
      unescaped_name = CGI.unescapeHTML(event.name)
      next if unescaped_name == event.name
      DB.exec(<<~SQL, unescaped_name: unescaped_name, id: event.id)
        UPDATE discourse_post_event_events SET name = :unescaped_name WHERE id = :id
      SQL
    end
  end

  def down
    raise ActiveRecord::IrreversibleMigration
  end
end
            event[name.sub('data-', '').to_sym] = if name == "data-name"
              value
            else
              CGI.escapeHTML(value)
            end
  it 'doesn’t escape event name' do
    events = subject.extract_events(build_post(user, '[event start="foo" name="bar <script> baz"]\n[/event]'))
    expect(events[0][:name]).to eq("bar <script> baz")
  end