CVE-2023-36461: Allocation of Resources Without Limits or Throttling (CWE-770)
value: Mastodon is a free, open-source social network server based on ActivityPub. When performing outgoing HTTP queries, Mastodon sets a timeout on individual read operations. Prior to versions 3.5.9, 4.0.5, and 4.1.3, a malicious server can indefinitely extend the duration of the response through slowloris-type attacks. This vulnerability can be used to keep all Mastodon workers busy for an extended duration of time, leading to the server becoming unresponsive. Versions 3.5.9, 4.0.5, and 4.1.3 contain a patch for this issue.
CWE 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.
CVSS v3 base score: 7.5
Published: 2023-07-06T19:15Z
Repository: https://github.com/mastodon/mastodon
Language: Ruby
Files changed: app/lib/request.rb
Commit date: 2023-07-06 15:06:23 +0200
Commit message: Merge pull request from GHSA-9pxv-6qvf-pjwc

* Fix timeout handling of outbound HTTP requests

* Use CLOCK_MONOTONIC instead of Time.now
Diff: diff --git a/app/lib/request.rb b/app/lib/request.rb
index 4bde6fc911..425effa1ac 100644
--- a/app/lib/request.rb
+++ b/app/lib/request.rb
@@ -5,15 +5,52 @@ require 'socket'
 require 'resolv'
 
 # Monkey-patch the HTTP.rb timeout class to avoid using a timeout block
 # around the Socket#open method, since we use our own timeout blocks inside
 # that method
+#
+# Also changes how the read timeout behaves so that it is cumulative (closer
+# to HTTP::Timeout::Global, but still having distinct timeouts for other
+# operation types)
 class HTTP::Timeout::PerOperation
   def connect(socket_class, host, port, nodelay = false)
     @socket = socket_class.open(host, port)
     @socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) if nodelay
   end
+
+  # Reset deadline when the connection is re-used for different requests
+  def reset_counter
+    @deadline = nil
+  end
+
+  # Read data from the socket
+  def readpartial(size, buffer = nil)
+    @deadline ||= Process.clock_gettime(Process::CLOCK_MONOTONIC) + @read_timeout
+
+    timeout = false
+    loop do
+      result = @socket.read_nonblock(size, buffer, exception: false)
+
+      return :eof if result.nil?
+
+      remaining_time = @deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
+      raise HTTP::TimeoutError, "Read timed out after #{@read_timeout} seconds" if timeout || remaining_time <= 0
+      return result if result != :wait_readable
+
+      # marking the socket for timeout. Why is this not being raised immediately?
+      # it seems there is some race-condition on the network level between calling
+      # #read_nonblock and #wait_readable, in which #read_nonblock signalizes waiting
+      # for reads, and when waiting for x seconds, it returns nil suddenly without completing
+      # the x seconds. In a normal case this would be a timeout on wait/read, but it can
+      # also mean that the socket has been closed by the server. Therefore we "mark" the
+      # socket for timeout and try to read more bytes. If it returns :eof, it's all good, no
+      # timeout. Else, the first timeout was a proper timeout.
+      # This hack has to be done because io/wait#wait_readable doesn't provide a value for when
+      # the socket is closed by the server, and HTTP::Parser doesn't provide the limit for the chunks.
+      timeout = true unless @socket.to_io.wait_readable(remaining_time)
+    end
+  end
 end
 
 class Request
   REQUEST_TARGET = '(request-target)'
 

Fixed code: #
# Also changes how the read timeout behaves so that it is cumulative (closer
# to HTTP::Timeout::Global, but still having distinct timeouts for other
# operation types)

  # Reset deadline when the connection is re-used for different requests
  def reset_counter
    @deadline = nil
  end

  # Read data from the socket
  def readpartial(size, buffer = nil)
    @deadline ||= Process.clock_gettime(Process::CLOCK_MONOTONIC) + @read_timeout

    timeout = false
    loop do
      result = @socket.read_nonblock(size, buffer, exception: false)

      return :eof if result.nil?

      remaining_time = @deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
      raise HTTP::TimeoutError, "Read timed out after #{@read_timeout} seconds" if timeout || remaining_time <= 0
      return result if result != :wait_readable

      # marking the socket for timeout. Why is this not being raised immediately?
      # it seems there is some race-condition on the network level between calling
      # #read_nonblock and #wait_readable, in which #read_nonblock signalizes waiting
      # for reads, and when waiting for x seconds, it returns nil suddenly without completing
      # the x seconds. In a normal case this would be a timeout on wait/read, but it can
      # also mean that the socket has been closed by the server. Therefore we "mark" the
      # socket for timeout and try to read more bytes. If it returns :eof, it's all good, no
      # timeout. Else, the first timeout was a proper timeout.
      # This hack has to be done because io/wait#wait_readable doesn't provide a value for when
      # the socket is closed by the server, and HTTP::Parser doesn't provide the limit for the chunks.
      timeout = true unless @socket.to_io.wait_readable(remaining_time)
    end
  end