CVE-2023-47119: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') (CWE-79)
value: Discourse is an open source platform for community discussion. Prior to version 3.1.3 of the `stable` branch and version 3.2.0.beta3 of the `beta` and `tests-passed` branches, some links can inject arbitrary HTML tags when rendered through our Onebox engine. The issue is patched in version 3.1.3 of the `stable` branch and version 3.2.0.beta3 of the `beta` and `tests-passed` branches. There are no known workarounds.
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-11-10T15:15Z
Repository: https://github.com/discourse/discourse
Language: Ruby
Files changed: lib/onebox/engine/github_issue_onebox.rb, lib/onebox/templates/discourse_category_onebox.mustache, spec/fixtures/onebox/github_issue_onebox.response, spec/lib/onebox/engine/github_issue_onebox_spec.rb
Commit date: 2023-11-09 13:39:11 +1100
Commit message: SECURITY: Onebox templates' HTML injections.

The use of triple-curlies on Mustache templates opens the possibility for HTML injections.

Diff: diff --git a/lib/onebox/engine/github_issue_onebox.rb b/lib/onebox/engine/github_issue_onebox.rb
index 510fc0ca3e2f..ef09cac8f9b5 100644
--- a/lib/onebox/engine/github_issue_onebox.rb
+++ b/lib/onebox/engine/github_issue_onebox.rb
@@ -34,11 +34,11 @@ module Onebox
         created_at = Time.parse(raw["created_at"])
         closed_at = Time.parse(raw["closed_at"]) if raw["closed_at"]
         body, excerpt = compute_body(raw["body"])
         ulink = URI(link)
 
-        labels = raw["labels"].map { |l| { name: Emoji.codes_to_img(l["name"]) } }
+        labels = raw["labels"].map { |l| { name: Emoji.codes_to_img(CGI.escapeHTML(l["name"])) } }
 
         {
           link: @url,
           title: raw["title"],
           body: body,
diff --git a/lib/onebox/templates/discourse_category_onebox.mustache b/lib/onebox/templates/discourse_category_onebox.mustache
index fb8ddd3300c9..24c9fcb3179b 100644
--- a/lib/onebox/templates/discourse_category_onebox.mustache
+++ b/lib/onebox/templates/discourse_category_onebox.mustache
@@ -4,29 +4,29 @@
       <img src="{{logo_url}}" class="thumbnail" />
     {{/logo_url}}
     <h3>
       <a class="badge-wrapper bullet" href="{{url}}">
         {{#color}}
-          <span class="badge-category-bg" style="background-color: #{{{color}}}"></span>
+          <span class="badge-category-bg" style="background-color: #{{color}}"></span>
         {{/color}}
-        <span class="clear-badge"><span>{{{name}}}</span></span>
+        <span class="clear-badge"><span>{{name}}</span></span>
       </a>
     </h3>
     {{#description}}
       <div>
         <span class="description">
-          <p>{{{description}}}</p>
+          <p>{{description}}</p>
         </span>
       </div>
     {{/description}}
     {{#has_subcategories}}
       <div class="subcategories">
         {{#subcategories}}
           <span class="subcategory">
             <a class="badge-wrapper bullet" href="{{url}}">
-              <span class="badge-category-bg" style="background-color: #{{{color}}}"></span>
-              <span class="badge-category clear-badge"><span class="category-name">{{{name}}}</span></span>
+              <span class="badge-category-bg" style="background-color: #{{color}}"></span>
+              <span class="badge-category clear-badge"><span class="category-name">{{name}}</span></span>
             </a>
           </span>
         {{/subcategories}}
       </div>
       <div class="clearfix"></div>
diff --git a/spec/fixtures/onebox/github_issue_onebox.response b/spec/fixtures/onebox/github_issue_onebox.response
new file mode 100644
index 000000000000..29d16c20f23b
--- /dev/null
+++ b/spec/fixtures/onebox/github_issue_onebox.response
@@ -0,0 +1,73 @@
+{
+  "url": "https://api.github.com/repos/romanrizzi/avalancha-parser/issues/2",
+  "repository_url": "https://api.github.com/repos/romanrizzi/avalancha-parser",
+  "labels_url": "https://api.github.com/repos/romanrizzi/avalancha-parser/issues/2/labels{/name}",
+  "comments_url": "https://api.github.com/repos/romanrizzi/avalancha-parser/issues/2/comments",
+  "events_url": "https://api.github.com/repos/romanrizzi/avalancha-parser/issues/2/events",
+  "html_url": "https://github.com/romanrizzi/avalancha-parser/issues/2",
+  "id": 1957276127,
+  "node_id": "I_kwDOEdKQe850qanf",
+  "number": 2,
+  "title": "Test issue #2",
+  "user": {
+    "login": "romanrizzi",
+    "id": 5025816,
+    "node_id": "MDQ6VXNlcjUwMjU4MTY=",
+    "avatar_url": "https://avatars.githubusercontent.com/u/5025816?v=4",
+    "gravatar_id": "",
+    "url": "https://api.github.com/users/romanrizzi",
+    "html_url": "https://github.com/romanrizzi",
+    "followers_url": "https://api.github.com/users/romanrizzi/followers",
+    "following_url": "https://api.github.com/users/romanrizzi/following{/other_user}",
+    "gists_url": "https://api.github.com/users/romanrizzi/gists{/gist_id}",
+    "starred_url": "https://api.github.com/users/romanrizzi/starred{/owner}{/repo}",
+    "subscriptions_url": "https://api.github.com/users/romanrizzi/subscriptions",
+    "organizations_url": "https://api.github.com/users/romanrizzi/orgs",
+    "repos_url": "https://api.github.com/users/romanrizzi/repos",
+    "events_url": "https://api.github.com/users/romanrizzi/events{/privacy}",
+    "received_events_url": "https://api.github.com/users/romanrizzi/received_events",
+    "type": "User",
+    "site_admin": false
+  },
+  "labels": [
+    {
+      "id": 6119137046,
+      "node_id": "LA_kwDOEdKQe88AAAABbLqfFg",
+      "url": "https://api.github.com/repos/romanrizzi/avalancha-parser/labels/Test%20:+1:%20%3Cstyle%3Ebody%20%7Bdisplay:%20none%7D%3C/style%3E",
+      "name": "Test :+1: <style>body {display: none}</style>",
+      "color": "0E8A16",
+      "default": false,
+      "description": ""
+    }
+  ],
+  "state": "open",
+  "locked": false,
+  "assignee": null,
+  "assignees": [
+
+  ],
+  "milestone": null,
+  "comments": 0,
+  "created_at": "2023-10-23T14:13:07Z",
+  "updated_at": "2023-10-23T14:13:07Z",
+  "closed_at": null,
+  "author_association": "OWNER",
+  "active_lock_reason": null,
+  "body": "test",
+  "closed_by": null,
+  "reactions": {
+    "url": "https://api.github.com/repos/romanrizzi/avalancha-parser/issues/2/reactions",
+    "total_count": 0,
+    "+1": 0,
+    "-1": 0,
+    "laugh": 0,
+    "hooray": 0,
+    "confused": 0,
+    "heart": 0,
+    "rocket": 0,
+    "eyes": 0
+  },
+  "timeline_url": "https://api.github.com/repos/romanrizzi/avalancha-parser/issues/2/timeline",
+  "performed_via_github_app": null,
+  "state_reason": null
+}
diff --git a/spec/lib/onebox/engine/github_issue_onebox_spec.rb b/spec/lib/onebox/engine/github_issue_onebox_spec.rb
new file mode 100644
index 000000000000..d2507e8f9bd9
--- /dev/null
+++ b/spec/lib/onebox/engine/github_issue_onebox_spec.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+RSpec.describe Onebox::Engine::GithubIssueOnebox do
+  before do
+    @link = "https://github.com/discourse/discourse/issues/1"
+
+    stub_request(:get, "https://api.github.com/repos/discourse/discourse/issues/1").to_return(
+      status: 200,
+      body: onebox_response("github_issue_onebox"),
+    )
+  end
+
+  include_context "with engines"
+  it_behaves_like "an engine"
+
+  describe "#to_html" do
+    it "sanitizes the input and transform the emoji into an img tag" do
+      sanitized_label =
+        'Test <img src="/images/emoji/twitter/+1.png?v=12" title="+1" class="emoji" alt="+1" loading="lazy" width="20" height="20"> &lt;style&gt;body {display: none}&lt;/style&gt;'
+
+      expect(html).to include(sanitized_label)
+    end
+  end
+end

Vulnerable code:         labels = raw["labels"].map { |l| { name: Emoji.codes_to_img(l["name"]) } }
          <span class="badge-category-bg" style="background-color: #{{{color}}}"></span>
        <span class="clear-badge"><span>{{{name}}}</span></span>
          <p>{{{description}}}</p>
              <span class="badge-category-bg" style="background-color: #{{{color}}}"></span>
              <span class="badge-category clear-badge"><span class="category-name">{{{name}}}</span></span>
Fixed code:         labels = raw["labels"].map { |l| { name: Emoji.codes_to_img(CGI.escapeHTML(l["name"])) } }
          <span class="badge-category-bg" style="background-color: #{{color}}"></span>
        <span class="clear-badge"><span>{{name}}</span></span>
          <p>{{description}}</p>
              <span class="badge-category-bg" style="background-color: #{{color}}"></span>
              <span class="badge-category clear-badge"><span class="category-name">{{name}}</span></span>
{
  "url": "https://api.github.com/repos/romanrizzi/avalancha-parser/issues/2",
  "repository_url": "https://api.github.com/repos/romanrizzi/avalancha-parser",
  "labels_url": "https://api.github.com/repos/romanrizzi/avalancha-parser/issues/2/labels{/name}",
  "comments_url": "https://api.github.com/repos/romanrizzi/avalancha-parser/issues/2/comments",
  "events_url": "https://api.github.com/repos/romanrizzi/avalancha-parser/issues/2/events",
  "html_url": "https://github.com/romanrizzi/avalancha-parser/issues/2",
  "id": 1957276127,
  "node_id": "I_kwDOEdKQe850qanf",
  "number": 2,
  "title": "Test issue #2",
  "user": {
    "login": "romanrizzi",
    "id": 5025816,
    "node_id": "MDQ6VXNlcjUwMjU4MTY=",
    "avatar_url": "https://avatars.githubusercontent.com/u/5025816?v=4",
    "gravatar_id": "",
    "url": "https://api.github.com/users/romanrizzi",
    "html_url": "https://github.com/romanrizzi",
    "followers_url": "https://api.github.com/users/romanrizzi/followers",
    "following_url": "https://api.github.com/users/romanrizzi/following{/other_user}",
    "gists_url": "https://api.github.com/users/romanrizzi/gists{/gist_id}",
    "starred_url": "https://api.github.com/users/romanrizzi/starred{/owner}{/repo}",
    "subscriptions_url": "https://api.github.com/users/romanrizzi/subscriptions",
    "organizations_url": "https://api.github.com/users/romanrizzi/orgs",
    "repos_url": "https://api.github.com/users/romanrizzi/repos",
    "events_url": "https://api.github.com/users/romanrizzi/events{/privacy}",
    "received_events_url": "https://api.github.com/users/romanrizzi/received_events",
    "type": "User",
    "site_admin": false
  },
  "labels": [
    {
      "id": 6119137046,
      "node_id": "LA_kwDOEdKQe88AAAABbLqfFg",
      "url": "https://api.github.com/repos/romanrizzi/avalancha-parser/labels/Test%20:+1:%20%3Cstyle%3Ebody%20%7Bdisplay:%20none%7D%3C/style%3E",
      "name": "Test :+1: <style>body {display: none}</style>",
      "color": "0E8A16",
      "default": false,
      "description": ""
    }
  ],
  "state": "open",
  "locked": false,
  "assignee": null,
  "assignees": [

  ],
  "milestone": null,
  "comments": 0,
  "created_at": "2023-10-23T14:13:07Z",
  "updated_at": "2023-10-23T14:13:07Z",
  "closed_at": null,
  "author_association": "OWNER",
  "active_lock_reason": null,
  "body": "test",
  "closed_by": null,
  "reactions": {
    "url": "https://api.github.com/repos/romanrizzi/avalancha-parser/issues/2/reactions",
    "total_count": 0,
    "+1": 0,
    "-1": 0,
    "laugh": 0,
    "hooray": 0,
    "confused": 0,
    "heart": 0,
    "rocket": 0,
    "eyes": 0
  },
  "timeline_url": "https://api.github.com/repos/romanrizzi/avalancha-parser/issues/2/timeline",
  "performed_via_github_app": null,
  "state_reason": null
}
# frozen_string_literal: true

RSpec.describe Onebox::Engine::GithubIssueOnebox do
  before do
    @link = "https://github.com/discourse/discourse/issues/1"

    stub_request(:get, "https://api.github.com/repos/discourse/discourse/issues/1").to_return(
      status: 200,
      body: onebox_response("github_issue_onebox"),
    )
  end

  include_context "with engines"
  it_behaves_like "an engine"

  describe "#to_html" do
    it "sanitizes the input and transform the emoji into an img tag" do
      sanitized_label =
        'Test <img src="/images/emoji/twitter/+1.png?v=12" title="+1" class="emoji" alt="+1" loading="lazy" width="20" height="20"> &lt;style&gt;body {display: none}&lt;/style&gt;'

      expect(html).to include(sanitized_label)
    end
  end
end