CVE-2023-44384: Server-Side Request Forgery (SSRF) (CWE-918)
value: 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. 
CWE 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.
CVSS v3 base score: 4.1
Published: 2023-10-06T18:15Z
Repository: https://github.com/discourse/discourse-jira
Language: Ruby
Files changed: app/controllers/discourse_jira/issues_controller.rb, lib/discourse_jira/api.rb, spec/lib/api_spec.rb, spec/requests/issues_controller_spec.rb
Commit date: 2023-10-04 14:19:59 +0530
Commit message: 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>
Diff: diff --git a/app/controllers/discourse_jira/issues_controller.rb b/app/controllers/discourse_jira/issues_controller.rb
index 4f0ed02..a29b4e1 100644
--- a/app/controllers/discourse_jira/issues_controller.rb
+++ b/app/controllers/discourse_jira/issues_controller.rb
@@ -128,15 +128,19 @@ module DiscourseJira
     end
 
     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}")
           render_json_error(
             I18n.t("discourse_jira.bad_api_response", status_code: response.code),
@@ -148,16 +152,16 @@ module DiscourseJira
         json = JSON.parse(response.body, symbolize_names: true)
 
         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)
             topic.add_moderator_post(
               current_user,
diff --git a/lib/discourse_jira/api.rb b/lib/discourse_jira/api.rb
index 9b3661b..9ba2769 100644
--- a/lib/discourse_jira/api.rb
+++ b/lib/discourse_jira/api.rb
@@ -1,10 +1,12 @@
 # frozen_string_literal: true
 
 module DiscourseJira
   class InvalidApiResponse < ::StandardError
   end
+  class InvalidURI < StandardError
+  end
 
   class Api
     INVALID_RESPONSE = "Invalid response from Jira API server"
 
     def self.get_version!
@@ -27,11 +29,11 @@ module DiscourseJira
       else
         endpoint = "rest/api/2/#{endpoint}" unless endpoint.start_with?("rest/api/2/")
         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",
           "Authorization" =>
             "Basic " +
@@ -41,14 +43,19 @@ 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)
       response = get(endpoint)
 
@@ -62,11 +69,11 @@ module DiscourseJira
       JSON.parse(response.body, symbolize_names: true)
     end
 
     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
       end
     end
diff --git a/spec/lib/api_spec.rb b/spec/lib/api_spec.rb
index bbfcbfe..fb1763c 100644
--- a/spec/lib/api_spec.rb
+++ b/spec/lib/api_spec.rb
@@ -9,11 +9,27 @@ RSpec.describe ::DiscourseJira::Api do
     SiteSetting.discourse_jira_username = "jira"
     SiteSetting.discourse_jira_password = "password"
   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: {
           baseUrl: "https://jira.common.bluescape.com",
           version: "9.4.3",
diff --git a/spec/requests/issues_controller_spec.rb b/spec/requests/issues_controller_spec.rb
index 6596a8c..731f280 100644
--- a/spec/requests/issues_controller_spec.rb
+++ b/spec/requests/issues_controller_spec.rb
@@ -221,10 +221,27 @@ describe DiscourseJira::IssuesController do
 
       post "/jira/issues/attach.json"
       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)
 
       stub_request(:get, "https://example.com/rest/api/2/issue/10041").to_return(

Vulnerable code:         response = Api.get("issue/#{params[:issue_key]}")
              issue_key: json[:key],
              issue_url: URI.join(SiteSetting.discourse_jira_url, "browse/#{json[:key]}").to_s,
        post.jira_issue_key = result[:issue_key]
      Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
      make_request(endpoint) { |uri, headers| Net::HTTP::Get.new(uri, headers) }
        request = Net::HTTP::Post.new(uri, headers)
Fixed code:       params.require(:issue_key)
      issue_key = params[:issue_key]
      raise Discourse::InvalidParameters.new(:issue_key) if issue_key.include?("/")

        response = Api.get("issue/#{issue_key}")
              issue_key: issue_key,
              issue_url: URI.join(SiteSetting.discourse_jira_url, "browse/#{issue_key}").to_s,
        post.jira_issue_key = issue_key
  class InvalidURI < StandardError
  end
      FinalDestination::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
    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
      make_request(endpoint) { |uri, headers| FinalDestination::HTTP::Get.new(uri, headers) }
        request = FinalDestination::HTTP::Post.new(uri, headers)
    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

      FinalDestination::SSRFDetector.stubs(:lookup_ips).returns(["1.2.3.4"])
    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