CVE-2024-23654: Insufficient Information (NVD-CWE-noinfo)
value: discourse-ai is the AI plugin for the open-source discussion platform Discourse. Prior to commit 94ba0dadc2cf38e8f81c3936974c167219878edd, interactions with different AI services are vulnerable to admin-initiated SSRF attacks. Versions of the plugin that include commit 94ba0dadc2cf38e8f81c3936974c167219878edd contain a patch. As a workaround, one may disable the discourse-ai plugin.

CWE description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
Published: 2024-02-21T21:15Z
Repository: https://github.com/discourse/discourse-ai
Language: Ruby
Files changed: lib/completions/endpoints/base.rb, lib/inference/cloudflare_workers_ai.rb, lib/inference/discourse_classifier.rb, lib/inference/discourse_reranker.rb, lib/inference/gemini_embeddings.rb, lib/inference/hugging_face_text_embeddings.rb, lib/inference/open_ai_embeddings.rb, lib/inference/open_ai_image_generator.rb, lib/inference/stability_generator.rb, spec/lib/completions/endpoints/endpoint_compliance.rb
Commit date: 2024-02-21 17:14:50 -0300
Commit message: SECURITY: Place a SSRF protection when calling services from the plugin. (#485)

The Faraday adapter and `FinalDestionation::HTTP` will protect us from admin-initiated SSRF attacks when interacting with the external services powering this plugin features.:
Diff: diff --git a/lib/completions/endpoints/base.rb b/lib/completions/endpoints/base.rb
index e86a9a8..d9962a5 100644
--- a/lib/completions/endpoints/base.rb
+++ b/lib/completions/endpoints/base.rb
@@ -67,11 +67,11 @@ module DiscourseAi
 
           @streaming_mode = block_given?
 
           prompt = dialect.translate
 
-          Net::HTTP.start(
+          FinalDestination::HTTP.start(
             model_uri.host,
             model_uri.port,
             use_ssl: true,
             read_timeout: TIMEOUT,
             open_timeout: TIMEOUT,
diff --git a/lib/inference/cloudflare_workers_ai.rb b/lib/inference/cloudflare_workers_ai.rb
index 42b5039..134dc9f 100644
--- a/lib/inference/cloudflare_workers_ai.rb
+++ b/lib/inference/cloudflare_workers_ai.rb
@@ -12,11 +12,12 @@ module ::DiscourseAi
         base_url = "https://api.cloudflare.com/client/v4/accounts/#{account_id}/ai/run/@cf/"
         headers["Authorization"] = "Bearer #{token}"
 
         endpoint = "#{base_url}#{model}"
 
-        response = Faraday.post(endpoint, content.to_json, headers)
+        conn = Faraday.new { |f| f.adapter FinalDestination::FaradayAdapter }
+        response = conn.post(endpoint, content.to_json, headers)
 
         raise Net::HTTPBadResponse if ![200].include?(response.status)
 
         JSON.parse(response.body, symbolize_names: true)
       end
diff --git a/lib/inference/discourse_classifier.rb b/lib/inference/discourse_classifier.rb
index 041b746..3784a19 100644
--- a/lib/inference/discourse_classifier.rb
+++ b/lib/inference/discourse_classifier.rb
@@ -6,11 +6,12 @@ module ::DiscourseAi
       def self.perform!(endpoint, model, content, api_key)
         headers = { "Referer" => Discourse.base_url, "Content-Type" => "application/json" }
 
         headers["X-API-KEY"] = api_key if api_key.present?
 
-        response = Faraday.post(endpoint, { model: model, content: content }.to_json, headers)
+        conn = Faraday.new { |f| f.adapter FinalDestination::FaradayAdapter }
+        response = conn.post(endpoint, { model: model, content: content }.to_json, headers)
 
         raise Net::HTTPBadResponse if ![200, 415].include?(response.status)
 
         JSON.parse(response.body, symbolize_names: true)
       end
diff --git a/lib/inference/discourse_reranker.rb b/lib/inference/discourse_reranker.rb
index 7651388..35b0979 100644
--- a/lib/inference/discourse_reranker.rb
+++ b/lib/inference/discourse_reranker.rb
@@ -6,12 +6,13 @@ module ::DiscourseAi
       def self.perform!(endpoint, model, content, candidates, api_key)
         headers = { "Referer" => Discourse.base_url, "Content-Type" => "application/json" }
 
         headers["X-API-KEY"] = api_key if api_key.present?
 
+        conn = Faraday.new { |f| f.adapter FinalDestination::FaradayAdapter }
         response =
-          Faraday.post(
+          conn.post(
             endpoint,
             { model: model, content: content, candidates: candidates }.to_json,
             headers,
           )
 
diff --git a/lib/inference/gemini_embeddings.rb b/lib/inference/gemini_embeddings.rb
index 933b0fe..0bd7864 100644
--- a/lib/inference/gemini_embeddings.rb
+++ b/lib/inference/gemini_embeddings.rb
@@ -9,11 +9,12 @@ module ::DiscourseAi
         url =
           "https://generativelanguage.googleapis.com/v1beta/models/embedding-001:embedContent\?key\=#{SiteSetting.ai_gemini_api_key}"
 
         body = { content: { parts: [{ text: content }] } }
 
-        response = Faraday.post(url, body.to_json, headers)
+        conn = Faraday.new { |f| f.adapter FinalDestination::FaradayAdapter }
+        response = conn.post(url, body.to_json, headers)
 
         raise Net::HTTPBadResponse if ![200].include?(response.status)
 
         JSON.parse(response.body, symbolize_names: true)
       end
diff --git a/lib/inference/hugging_face_text_embeddings.rb b/lib/inference/hugging_face_text_embeddings.rb
index 09118bc..6ec7af8 100644
--- a/lib/inference/hugging_face_text_embeddings.rb
+++ b/lib/inference/hugging_face_text_embeddings.rb
@@ -16,11 +16,12 @@ module ::DiscourseAi
 
         if SiteSetting.ai_hugging_face_tei_api_key.present?
           headers["X-API-KEY"] = SiteSetting.ai_hugging_face_tei_api_key
         end
 
-        response = Faraday.post(api_endpoint, body, headers)
+        conn = Faraday.new { |f| f.adapter FinalDestination::FaradayAdapter }
+        response = conn.post(api_endpoint, body, headers)
 
         raise Net::HTTPBadResponse if ![200].include?(response.status)
 
         JSON.parse(response.body, symbolize_names: true)
       end
diff --git a/lib/inference/open_ai_embeddings.rb b/lib/inference/open_ai_embeddings.rb
index 6826a89..9ffcaa4 100644
--- a/lib/inference/open_ai_embeddings.rb
+++ b/lib/inference/open_ai_embeddings.rb
@@ -13,11 +13,12 @@ module ::DiscourseAi
         end
 
         payload = { model: model, input: content }
         payload[:dimensions] = dimensions if dimensions.present?
 
-        response = Faraday.post(SiteSetting.ai_openai_embeddings_url, payload.to_json, headers)
+        conn = Faraday.new { |f| f.adapter FinalDestination::FaradayAdapter }
+        response = conn.post(SiteSetting.ai_openai_embeddings_url, payload.to_json, headers)
 
         case response.status
         when 200
           JSON.parse(response.body, symbolize_names: true)
         when 429
diff --git a/lib/inference/open_ai_image_generator.rb b/lib/inference/open_ai_image_generator.rb
index f8cc203..d91168f 100644
--- a/lib/inference/open_ai_image_generator.rb
+++ b/lib/inference/open_ai_image_generator.rb
@@ -26,11 +26,11 @@ module ::DiscourseAi
           n: 1,
           size: size,
           response_format: "b64_json",
         }
 
-        Net::HTTP.start(
+        FinalDestination::HTTP.start(
           uri.host,
           uri.port,
           use_ssl: uri.scheme == "https",
           read_timeout: TIMEOUT,
           open_timeout: TIMEOUT,
diff --git a/lib/inference/stability_generator.rb b/lib/inference/stability_generator.rb
index cdd1a7b..47bc681 100644
--- a/lib/inference/stability_generator.rb
+++ b/lib/inference/stability_generator.rb
@@ -55,11 +55,12 @@ module ::DiscourseAi
 
         payload[:seed] = seed if seed
 
         endpoint = "v1/generation/#{engine}/text-to-image"
 
-        response = Faraday.post("#{api_url}/#{endpoint}", payload.to_json, headers)
+        conn = Faraday.new { |f| f.adapter FinalDestination::FaradayAdapter }
+        response = conn.post("#{api_url}/#{endpoint}", payload.to_json, headers)
 
         if response.status != 200
           Rails.logger.error(
             "AI stability generator failed with status #{response.status}: #{response.body}}",
           )
diff --git a/spec/lib/completions/endpoints/endpoint_compliance.rb b/spec/lib/completions/endpoints/endpoint_compliance.rb
index 0fc3671..d26ec91 100644
--- a/spec/lib/completions/endpoints/endpoint_compliance.rb
+++ b/spec/lib/completions/endpoints/endpoint_compliance.rb
@@ -97,25 +97,25 @@ class EndpointMock
     }
   end
 
   def with_chunk_array_support
     mock = mocked_http
-    @original_net_http = ::Net.send(:remove_const, :HTTP)
-    ::Net.send(:const_set, :HTTP, mock)
+    @original_net_http = ::FinalDestination.send(:remove_const, :HTTP)
+    ::FinalDestination.send(:const_set, :HTTP, mock)
 
     yield
   ensure
-    ::Net.send(:remove_const, :HTTP)
-    ::Net.send(:const_set, :HTTP, @original_net_http)
+    ::FinalDestination.send(:remove_const, :HTTP)
+    ::FinalDestination.send(:const_set, :HTTP, @original_net_http)
   end
 
   protected
 
   # Copied from https://github.com/bblimke/webmock/issues/629
   # Workaround for stubbing a streamed response
   def mocked_http
-    Class.new(::Net::HTTP) do
+    Class.new(FinalDestination::HTTP) do
       def request(*)
         super do |response|
           response.instance_eval do
             def read_body(*, &block)
               if block_given?

Vulnerable code:           Net::HTTP.start(
        response = Faraday.post(endpoint, content.to_json, headers)
        response = Faraday.post(endpoint, { model: model, content: content }.to_json, headers)
          Faraday.post(
        response = Faraday.post(url, body.to_json, headers)
        response = Faraday.post(api_endpoint, body, headers)
        response = Faraday.post(SiteSetting.ai_openai_embeddings_url, payload.to_json, headers)
        Net::HTTP.start(
        response = Faraday.post("#{api_url}/#{endpoint}", payload.to_json, headers)
    @original_net_http = ::Net.send(:remove_const, :HTTP)
    ::Net.send(:const_set, :HTTP, mock)
    ::Net.send(:remove_const, :HTTP)
    ::Net.send(:const_set, :HTTP, @original_net_http)
    Class.new(::Net::HTTP) do
Fixed code:           FinalDestination::HTTP.start(
        conn = Faraday.new { |f| f.adapter FinalDestination::FaradayAdapter }
        response = conn.post(endpoint, content.to_json, headers)
        conn = Faraday.new { |f| f.adapter FinalDestination::FaradayAdapter }
        response = conn.post(endpoint, { model: model, content: content }.to_json, headers)
        conn = Faraday.new { |f| f.adapter FinalDestination::FaradayAdapter }
          conn.post(
        conn = Faraday.new { |f| f.adapter FinalDestination::FaradayAdapter }
        response = conn.post(url, body.to_json, headers)
        conn = Faraday.new { |f| f.adapter FinalDestination::FaradayAdapter }
        response = conn.post(api_endpoint, body, headers)
        conn = Faraday.new { |f| f.adapter FinalDestination::FaradayAdapter }
        response = conn.post(SiteSetting.ai_openai_embeddings_url, payload.to_json, headers)
        FinalDestination::HTTP.start(
        conn = Faraday.new { |f| f.adapter FinalDestination::FaradayAdapter }
        response = conn.post("#{api_url}/#{endpoint}", payload.to_json, headers)
    @original_net_http = ::FinalDestination.send(:remove_const, :HTTP)
    ::FinalDestination.send(:const_set, :HTTP, mock)
    ::FinalDestination.send(:remove_const, :HTTP)
    ::FinalDestination.send(:const_set, :HTTP, @original_net_http)
    Class.new(FinalDestination::HTTP) do