CVE-2023-38498 (published 2023-07-28T16:15Z)

Discourse is an open source discussion platform. Prior to version 3.0.6 of the `stable` branch and version 3.1.0.beta7 of the `beta` and `tests-passed` branches, a malicious user can prevent the defer queue from proceeding promptly on sites hosted in the same multisite installation. The issue is patched in version 3.0.6 of the `stable` branch and version 3.1.0.beta7 of the `beta` and `tests-passed` branches. There are no known workarounds for this vulnerability. Users of multisite configurations should upgrade.

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

Weakness information:
cwe_id: CWE-770
cwe_name: Allocation of Resources Without Limits or Throttling
description: The product allocates a reusable resource or group of resources on behalf of an actor without imposing any restrictions on the size or number of resources that can be allocated, in violation of the intended security policy for that actor.
extended_description: Code frequently has to work with limited resources, so programmers must be careful to ensure that resources are not consumed too quickly, or too easily.  Without use of quotas, resource limits, or other protection mechanisms, it can be easy for an attacker to consume many resources by rapidly making many requests, or causing larger resources to be used than is needed. When too many resources are allocated, or if a single resource is too large, then it can prevent the code from working correctly, possibly leading to a denial of service.
         

Fix information:
repo_url: https://github.com/discourse/discourse
commit_details: repo_url: https://github.com/discourse/discourse
author: Daniel Waterworth
author_date: 2023-07-28 12:53:51+01:00
msg: SECURITY: Don't allow a particular site to monopolize the defer queue
repository: repo_url: https://github.com/discourse/discourse
repo_name: discourse/discourse
description: A platform for community discussion. Free, open, simple.
repo_language: Ruby
owner: discourse
file_changes: filename: hijack.rb
change_type: ModificationType.MODIFY
diff: @@ -23,13 +23,25 @@ module Hijack
 
       transfer_timings = MethodProfiler.transfer
 
-      io = hijack.call
+      scheduled = Concurrent::Promises.resolvable_event
+
+      begin
+        Scheduler::Defer.later(
+          "hijack #{params["controller"]} #{params["action"]} #{info}",
+          force: false,
+          &scheduled.method(:resolve)
+        )
+      rescue WorkQueue::WorkQueueFull
+        return render plain: "", status: 503
+      end
 
       # duplicate headers so other middleware does not mess with it
       # on the way down the stack
       original_headers = response.headers.dup
 
-      Scheduler::Defer.later("hijack #{params["controller"]} #{params["action"]} #{info}") do
+      io = hijack.call
+
+      scheduled.on_resolution! do
         MethodProfiler.start(transfer_timings)
         begin
           Thread.current[Logster::Logger::LOGSTER_ENV] = env
@@ -148,6 +160,7 @@ module Hijack
           tempfiles&.each(&:close!)
         end
       end
+
       # not leaked out, we use 418 ... I am a teapot to denote that we are hijacked
       render plain: "", status: 418
     else

programming_language: Ruby
filename: defer.rb
change_type: ModificationType.MODIFY
diff: @@ -8,7 +8,11 @@ module Scheduler
 
     def initialize
       @async = !Rails.env.test?
-      @queue = Queue.new
+      @queue =
+        WorkQueue::ThreadSafeWrapper.new(
+          WorkQueue::FairQueue.new(500) { WorkQueue::BoundedQueue.new(10) },
+        )
+
       @mutex = Mutex.new
       @stats_mutex = Mutex.new
       @paused = false
@@ -23,7 +27,7 @@ module Scheduler
     end
 
     def length
-      @queue.length
+      @queue.size
     end
 
     def stats
@@ -44,7 +48,7 @@ module Scheduler
       @async = val
     end
 
-    def later(desc = nil, db = RailsMultisite::ConnectionManagement.current_db, &blk)
+    def later(desc = nil, db = RailsMultisite::ConnectionManagement.current_db, force: true, &blk)
       @stats_mutex.synchronize do
         stats = (@stats[desc] ||= { queued: 0, finished: 0, duration: 0, errors: 0 })
         stats[:queued] += 1
@@ -52,7 +56,7 @@ module Scheduler
 
       if @async
         start_thread if !@thread&.alive? && !@paused
-        @queue << [db, blk, desc]
+        @queue.push({ key: db, task: [db, blk, desc] }, force: force)
       else
         blk.call
       end
@@ -71,7 +75,7 @@ module Scheduler
     end
 
     def do_all_work
-      do_work(_non_block = true) while !@queue.empty?
+      do_work(non_block = true) while !@queue.empty?
     end
 
     private
@@ -89,7 +93,8 @@ module Scheduler
 
     # using non_block to match Ruby #deq
     def do_work(non_block = false)
-      db, job, desc = @queue.deq(non_block)
+      db, job, desc = @queue.shift(block: !non_block)[:task]
+
       start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
       db ||= RailsMultisite::ConnectionManagement::DEFAULT
 

programming_language: Ruby
filename: work_queue.rb
change_type: ModificationType.ADD
diff: @@ -0,0 +1,111 @@
+# frozen_string_literal: true
+
+require "monitor"
+
+module WorkQueue
+  class WorkQueueFull < StandardError
+  end
+
+  class ThreadSafeWrapper
+    include MonitorMixin
+
+    def initialize(queue)
+      mon_initialize
+
+      @queue = queue
+      @has_items = new_cond
+    end
+
+    def push(task, force:)
+      synchronize do
+        previously_empty = @queue.empty?
+        @queue.push(task, force: force)
+
+        @has_items.signal if previously_empty
+      end
+    end
+
+    def shift(block:)
+      synchronize do
+        loop do
+          if task = @queue.shift
+            break task
+          elsif block
+            @has_items.wait
+          else
+            break nil
+          end
+        end
+      end
+    end
+
+    def empty?
+      synchronize { @queue.empty? }
+    end
+
+    def size
+      synchronize { @queue.size }
+    end
+  end
+
+  class FairQueue
+    attr_reader :size
+
+    def initialize(limit, &blk)
+      @limit = limit
+      @size = 0
+      @elements = Hash.new { |h, k| h[k] = blk.call }
+    end
+
+    def push(task, force:)
+      raise WorkQueueFull if !force && @size >= @limit
+      key, task = task.values_at(:key, :task)
+      @elements[key].push(task, force: force)
+      @size += 1
+      nil
+    end
+
+    def shift
+      unless @elements.empty?
+        key, queue = @elements.shift
+
+        task = queue.shift
+
+        @elements[key] = queue unless queue.empty?
+
+        @size -= 1
+
+        { key: key, task: task }
+      end
+    end
+
+    def empty?
+      @elements.empty?
+    end
+  end
+
+  class BoundedQueue
+    def initialize(limit)
+      @limit = limit
+      @elements = []
+    end
+
+    def push(task, force:)
+      raise WorkQueueFull if !force && @elements.size >= @limit
+      @elements << task
+      nil
+    end
+
+    def shift
+      @elements.shift
+    end
+
+    def empty?
+      @elements.empty?
+    end
+
+    def size
+      @elements.size
+    end
+  end
+end

programming_language: Ruby
filename: hijack_spec.rb
change_type: ModificationType.MODIFY
diff: @@ -225,4 +225,12 @@ RSpec.describe Hijack do
 
     expect(ran).to eq(false)
   end
+
+  it "handles the queue being full" do
+    Scheduler::Defer.stubs(:later).raises(WorkQueue::WorkQueueFull.new)
+
+    tester.hijack_test {}
+
+    expect(tester.response.status).to eq(503)
+  end
 end

programming_language: Ruby
filename: work_queue_spec.rb
change_type: ModificationType.ADD
diff: @@ -0,0 +1,216 @@
+# frozen_string_literal: true
+
+RSpec.describe WorkQueue::BoundedQueue do
+  subject(:queue) { WorkQueue::BoundedQueue.new(3) }
+
+  let(:task1) { "Task 1" }
+  let(:task2) { "Task 2" }
+  let(:task3) { "Task 3" }
+  let(:task4) { "Task 4" }
+
+  describe "#push" do
+    context "when the queue is not full" do
+      it "adds the task to the queue" do
+        queue.push(task1, force: false)
+        expect(queue.size).to eq(1)
+      end
+    end
+
+    context "when the queue is full" do
+      before do
+        queue.push(task1, force: false)
+        queue.push(task2, force: false)
+        queue.push(task3, force: false)
+      end
+
+      it "adds the task to the queue if force parameter is true" do
+        expect { queue.push(task4, force: true) }.not_to raise_error
+        expect(queue.size).to eq(4)
+      end
+
+      it "raises an error if the force parameter is false" do
+        expect { queue.push(task4, force: false) }.to raise_error(WorkQueue::WorkQueueFull)
+      end
+    end
+  end
+
+  describe "#shift" do
+    it "removes and returns the first task from the queue" do
+      queue.push(task1, force: false)
+      queue.push(task2, force: false)
+
+      expect(queue.shift).to eq(task1)
+      expect(queue.shift).to eq(task2)
+
+      expect(queue.size).to eq(0)
+      expect(queue).to be_empty
+    end
+
+    it "returns nil when the queue is empty" do
+      shifted_task = queue.shift
+      expect(shifted_task).to be_nil
+    end
+  end
+
+  describe "#empty?" do
+    it "returns true if the queue is empty" do
+      expect(queue).to be_empty
+    end
+
+    it "returns false if the queue is not empty" do
+      queue.push(task1, force: false)
+      expect(queue).not_to be_empty
+    end
+  end
+
+  describe "#size" do
+    it "returns the number of tasks in the queue" do
+      queue.push(task1, force: false)
+      queue.push(task2, force: false)
+      expect(queue.size).to eq(2)
+    end
+  end
+end
+
+RSpec.describe WorkQueue::FairQueue do
+  subject(:queue) do
+    WorkQueue::FairQueue.new(global_limit) { WorkQueue::BoundedQueue.new(per_key_limit) }
+  end
+
+  let(:global_limit) { 5 }
+  let(:per_key_limit) { 3 }
+  let(:key1) { :key1 }
+  let(:key2) { :key2 }
+  let(:key3) { :key3 }
+  let(:task1) { "task1" }
+  let(:task2) { "task2" }
+  let(:task3) { "task3" }
+  let(:task4) { "task4" }
+  let(:task5) { "task5" }
+  let(:task6) { "task6" }
+
+  describe "#push" do
+    context "when no previous tasks exist for the key" do
+      it "adds the task to the queue" do
+        queue.push({ key: key1, task: task1 }, force: false)
+        expect(queue.size).to eq(1)
+      end
+    end
+
+    context "when the global limit is reached" do
+      before do
+        queue.push({ key: key1, task: task1 }, force: false)
+        queue.push({ key: key2, task: task2 }, force: false)
+        queue.push({ key: key3, task: task3 }, force: false)
+        queue.push({ key: key1, task: task4 }, force: false)
+        queue.push({ key: key2, task: task5 }, force: false)
+      end
+
+      it "raises an error if the force parameter is false" do
+        expect { queue.push({ key: key3, task: task6 }, force: false) }.to raise_error(
+          WorkQueue::WorkQueueFull,
+        )
+      end
+
+      it "adds the task to the queue if the force parameter is true" do
+        queue.push({ key: key3, task: task6 }, force: true)
+        expect(queue.size).to eq(6)
+      end
+    end
+  end
+
+  describe "#shift" do
+    it "removes and returns tasks in FIFO order when the keys are different" do
+      queue.push({ key: key1, task: task1 }, force: false)
+      queue.push({ key: key2, task: task2 }, force: false)
+      queue.push({ key: key3, task: task3 }, force: false)
+
+      expect(queue.shift).to eq({ key: key1, task: task1 })
+      expect(queue.shift).to eq({ key: key2, task: task2 })
+      expect(queue.shift).to eq({ key: key3, task: task3 })
+
+      expect(queue.size).to eq(0)
+      expect(queue).to be_empty
+    end
+
+    it "removes and returns tasks in FIFO order by key when the keys are the same" do
+      queue.push({ key: key1, task: task1 }, force: false)
+      queue.push({ key: key1, task: task3 }, force: false)
+      queue.push({ key: key2, task: task2 }, force: false)
+      queue.push({ key: key2, task: task4 }, force: false)
+
+      expect(queue.shift).to eq({ key: key1, task: task1 })
+      expect(queue.shift).to eq({ key: key2, task: task2 })
+      expect(queue.shift).to eq({ key: key1, task: task3 })
+      expect(queue.shift).to eq({ key: key2, task: task4 })
+
+      expect(queue.size).to eq(0)
+      expect(queue).to be_empty
+    end
+
+    it "returns nil when the queue is empty" do
+      shifted_task = queue.shift
+      expect(shifted_task).to be_nil
+    end
+  end
+
+  describe "#empty?" do
+    it "returns true if the queue is empty" do
+      expect(queue).to be_empty
+    end
+
+    it "returns false if the queue is not empty" do
+      queue.push({ key: key1, task: task1 }, force: false)
+      expect(queue).not_to be_empty
+    end
+  end
+
+  describe "#size" do
+    it "returns the number of tasks in the queue" do
+      queue.push({ key: key1, task: task1 }, force: false)
+      queue.push({ key: key2, task: task2 }, force: false)
+      expect(queue.size).to eq(2)
+    end
+  end
+end
+
+RSpec.describe WorkQueue::ThreadSafeWrapper do
+  subject(:queue) { WorkQueue::ThreadSafeWrapper.new(WorkQueue::BoundedQueue.new(3)) }
+
+  let(:task) { "task1" }
+
+  describe "#push" do
+    it "delegates the push operation to the inner queue" do
+      queue.push(task, force: false)
+      expect(queue).not_to be_empty
+    end
+  end
+
+  describe "#shift" do
+    context "when block is true" do
+      it "waits until an item is available and then returns it" do
+        result = nil
+        thread = Thread.new { result = queue.shift(block: true) }
+        expect(thread).to be_alive
+
+        queue.push(task, force: false)
+        thread.join
+
+        expect(result).to eq(task)
+      end
+    end
+
+    context "when block is false" do
+      it "returns nil immediately if no item is available" do
+        shifted_task = queue.shift(block: false)
+        expect(shifted_task).to be_nil
+      end
+
+      it "returns the first available item if one is present" do
+        queue.push(task, force: false)
+        shifted_task = queue.shift(block: false)
+        expect(shifted_task).to eq(task)
+      end
+    end
+  end
+end

programming_language: Ruby