CVE-2023-44384 (published 2023-10-06T18:15Z)

Discourse-jira is a Discourse plugin allows Jira projects, issue types, fields and field options will be synced automatically. An administrator user can make an SSRF attack by setting the Jira URL to an arbitrary location and enabling the `discourse_jira_verbose_log` site setting. A moderator user could manipulate the request path to the Jira API, allowing them to perform arbitrary GET requests using the Jira API credentials, potentially with elevated permissions, used by the application. 

CVSS v3 base score: 4.1, MEDIUM (CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:C/C:L/I:N/A:N)

Weakness information:
cwe_id: CWE-918
cwe_name: Server-Side Request Forgery (SSRF)
description: The web server receives a URL or similar request from an upstream component and retrieves the contents of this URL, but it does not sufficiently ensure that the request is being sent to the expected destination.
extended_description: By providing URLs to unexpected hosts or ports, attackers can make it appear that the server is sending the request, possibly bypassing access controls such as firewalls that prevent the attackers from accessing the URLs directly. The server can be used as a proxy to conduct port scanning of hosts in internal networks, use other URLs such as that can access documents on the system (using file://), or use other protocols such as gopher:// or tftp://, which may provide greater control over the contents of requests.
         

Fix information:
repo_url: https://github.com/discourse/discourse-jira
commit_details: repo_url: https://github.com/discourse/discourse-jira
author: Vinoth Kannan
author_date: 2023-10-04 14:19:59+05:30
msg: SECURITY: use `FinalDestination` to prevent SSRF attacks on API calls. (#50)

Also, it will check the issue key value on the "attach" action and won't allow the `/` character in it.

Previously, an administrator could point the Jira server to an arbitrary location. After entering an issue key or an arbitrary path in the “Attach Issue” feature, the request failed, however, the output of the response was reflected in the error logs. This vulnerability could be used to enumerate ports on the remote host locally, as well as retrieve responses from non-public-facing services, such as the Prometheus Ruby Exporter. While the path to the issue lookup was automatically appended by the application, it was possible to use the “…/” notation to manipulate the path from the interface. Therefore, any user with the ability to add an existing issue to a POST could perform arbitrary GET requests against the Jira API, however, only the administrator could see the output in the error logs.

Co-authored-by: David Taylor <david@taylorhq.com>
repository: repo_url: https://github.com/discourse/discourse-jira
repo_name: discourse/discourse-jira
repo_language: Ruby
owner: discourse
file_changes: filename: issues_controller.rb
change_type: ModificationType.MODIFY
diff: @@ -130,11 +130,15 @@ module DiscourseJira
     def attach
       raise Discourse::InvalidAccess if !SiteSetting.discourse_jira_enabled
 
+      params.require(:issue_key)
+      issue_key = params[:issue_key]
+      raise Discourse::InvalidParameters.new(:issue_key) if issue_key.include?("/")
+
       hijack(
         info:
           "attaching Jira issue for topic #{params[:topic_id]} and post_number #{params[:post_number]}",
       ) do
-        response = Api.get("issue/#{params[:issue_key]}")
+        response = Api.get("issue/#{issue_key}")
 
         if response.code != "200"
           log("Bad Jira response: #{response.body}")
@@ -150,12 +154,12 @@ module DiscourseJira
         result =
           success_json.merge(
             {
-              issue_key: json[:key],
-              issue_url: URI.join(SiteSetting.discourse_jira_url, "browse/#{json[:key]}").to_s,
+              issue_key: issue_key,
+              issue_url: URI.join(SiteSetting.discourse_jira_url, "browse/#{issue_key}").to_s,
             },
           )
 
-        post.jira_issue_key = result[:issue_key]
+        post.jira_issue_key = issue_key
 
         if topic = Topic.find_by(id: params[:topic_id])
           if current_user.guardian.can_create_post_on_topic?(topic)

programming_language: Ruby
filename: api.rb
change_type: ModificationType.MODIFY
diff: @@ -3,6 +3,8 @@
 module DiscourseJira
   class InvalidApiResponse < ::StandardError
   end
+  class InvalidURI < StandardError
+  end
 
   class Api
     INVALID_RESPONSE = "Invalid response from Jira API server"
@@ -29,7 +31,7 @@ module DiscourseJira
         uri = URI.join(SiteSetting.discourse_jira_url, endpoint)
       end
 
-      Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
+      FinalDestination::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
         headers = {
           "Content-Type" => "application/json",
           "Accept" => "application/json",
@@ -43,10 +45,15 @@ module DiscourseJira
         request = yield(uri, headers)
         http.request(request)
       end
+    rescue FinalDestination::SSRFDetector::DisallowedIpError => e
+      Discourse.warn_exception(e, message: "SSRF detected", env: { url: uri.to_s })
+      raise InvalidURI
+    rescue SocketError, Timeout::Error
+      raise InvalidURI
     end
 
     def self.get(endpoint)
-      make_request(endpoint) { |uri, headers| Net::HTTP::Get.new(uri, headers) }
+      make_request(endpoint) { |uri, headers| FinalDestination::HTTP::Get.new(uri, headers) }
     end
 
     def self.getJSON(endpoint)
@@ -64,7 +71,7 @@ module DiscourseJira
 
     def self.post(endpoint, body)
       make_request(endpoint) do |uri, headers|
-        request = Net::HTTP::Post.new(uri, headers)
+        request = FinalDestination::HTTP::Post.new(uri, headers)
         request.body = body.to_json
 
         request

programming_language: Ruby
filename: api_spec.rb
change_type: ModificationType.MODIFY
diff: @@ -11,7 +11,23 @@ RSpec.describe ::DiscourseJira::Api do
   end
 
   describe ".get_version!" do
+    it "raises error for internal hosts" do
+      WebMock.enable!(except: [:final_destination])
+      FinalDestination::SSRFDetector.stubs(:lookup_ips).returns(["192.168.1.1"])
+      Discourse.expects(:warn_exception).with(
+        instance_of(FinalDestination::SSRFDetector::DisallowedIpError),
+        message: "SSRF detected",
+        env: {
+          url: "https://jira.example.com/rest/api/2/serverInfo",
+        },
+      )
+      expect { described_class.get_version! }.to raise_error(DiscourseJira::InvalidURI)
+    ensure
+      WebMock.enable!
+    end
+
     it "returns the API version" do
+      FinalDestination::SSRFDetector.stubs(:lookup_ips).returns(["1.2.3.4"])
       stub_request(:get, "https://jira.example.com/rest/api/2/serverInfo").to_return(
         status: 200,
         body: {

programming_language: Ruby
filename: issues_controller_spec.rb
change_type: ModificationType.MODIFY
diff: @@ -223,6 +223,23 @@ describe DiscourseJira::IssuesController do
       expect(response.status).to eq(404)
     end
 
+    it "requires issue key in correct format" do
+      sign_in(admin)
+      post = Fabricate(:post)
+
+      post "/jira/issues/attach.json",
+           params: {
+             issue_key: "../DIS/42",
+             topic_id: post.topic_id,
+             post_number: post.post_number,
+           }
+
+      expect(response.status).to eq(400)
+      expect(response.parsed_body["errors"][0]).to eq(
+        I18n.t("invalid_params", message: "issue_key"),
+      )
+    end
+
     it "attach an existing Jira issue to post" do
       sign_in(admin)
       post = Fabricate(:post)

programming_language: Markdown