From 2f62c5967baa1d7f8d9c65c1a920e833bcfde7d5 Mon Sep 17 00:00:00 2001
From: yast <yastcher@gmail.com>
Date: Sat, 11 Jul 2026 00:48:01 +0300
Subject: [PATCH] Add rate limiting for the REST API (#43881).

Callers exceeding Setting.rest_api_rate_limit requests per minute get HTTP 429 with a Retry-After header and an errors body. Requests are counted per user when authenticated, per source IP otherwise. Disabled by default.
---
 app/controllers/application_controller.rb     |  21 ++-
 app/views/settings/_api.html.erb              |   2 +
 config/locales/en.yml                         |   2 +
 config/settings.yml                           |   4 +
 lib/redmine/api_rate_limiter.rb               |  68 ++++++++++
 .../api_test/rate_limiting_test.rb            | 124 ++++++++++++++++++
 .../unit/lib/redmine/api_rate_limiter_test.rb |  76 +++++++++++
 7 files changed, 296 insertions(+), 1 deletion(-)
 create mode 100644 lib/redmine/api_rate_limiter.rb
 create mode 100644 test/integration/api_test/rate_limiting_test.rb
 create mode 100644 test/unit/lib/redmine/api_rate_limiter_test.rb

diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 5b389ddc8..3005cf65e 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -61,7 +61,7 @@ class ApplicationController < ActionController::Base
     end
   end
 
-  before_action :session_expiration, :user_setup, :check_if_login_required, :set_localization, :check_password_change, :check_twofa_activation
+  before_action :session_expiration, :user_setup, :check_api_rate_limit, :check_if_login_required, :set_localization, :check_password_change, :check_twofa_activation
   after_action :record_project_usage
 
   rescue_from ::Unauthorized, :with => :deny_access
@@ -172,6 +172,25 @@ class ApplicationController < ActionController::Base
     user
   end
 
+  # Rejects the request with 429 Too Many Requests when the API caller
+  # exceeded Setting.rest_api_rate_limit requests per minute.
+  # Requests are counted per user when authenticated, per source IP
+  # otherwise, so that repeated requests with invalid credentials are
+  # throttled as well. Web UI requests are never throttled.
+  def check_api_rate_limit
+    return unless api_request?
+
+    limit = Setting.rest_api_rate_limit.to_i
+    bucket = User.current.logged? ? "user/#{User.current.id}" : "ip/#{request.remote_ip}"
+    if Redmine::ApiRateLimiter.throttled?(bucket, limit)
+      logger.warn("API rate limit exceeded for #{bucket} (#{request.request_method} #{request.path})") if logger
+      response.headers['Retry-After'] = Redmine::ApiRateLimiter.retry_after.to_s
+      response.headers['X-RateLimit-Limit'] = limit.to_s
+      @error_messages = [l(:error_api_rate_limit_exceeded)]
+      render :template => 'common/error_messages', :format => [:api], :status => :too_many_requests, :layout => nil
+    end
+  end
+
   def autologin_cookie_name
     Redmine::Configuration['autologin_cookie_name'].presence || 'autologin'
   end
diff --git a/app/views/settings/_api.html.erb b/app/views/settings/_api.html.erb
index 3fb584ef9..1f02f2a2a 100644
--- a/app/views/settings/_api.html.erb
+++ b/app/views/settings/_api.html.erb
@@ -3,6 +3,8 @@
 <div class="box tabular settings">
 <p><%= setting_check_box :rest_api_enabled %></p>
 
+<p><%= setting_text_field :rest_api_rate_limit, :size => 6 %></p>
+
 <p><%= setting_check_box :jsonp_enabled %></p>
 
 <p><%= setting_check_box :webhooks_enabled %></p>
diff --git a/config/locales/en.yml b/config/locales/en.yml
index d0d93746a..2cfc9c1ff 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -253,6 +253,7 @@ en:
   error_attachment_not_found: "Attachment %{name} not found"
   error_invalid_authenticity_token: "Invalid form authenticity token."
   error_query_statement_invalid: "An error occurred while executing the query and has been logged. Please report this error to your Redmine administrator."
+  error_api_rate_limit_exceeded: "Rate limit exceeded. Please retry later."
 
   mail_subject_lost_password: "Your %{value} password"
   mail_body_lost_password: 'To change your password, click on the following link:'
@@ -493,6 +494,7 @@ en:
   setting_issue_done_ratio_issue_status: Use the issue status
   setting_start_of_week: Start calendars on
   setting_rest_api_enabled: Enable REST web service
+  setting_rest_api_rate_limit: Maximum number of API requests per minute and user or IP (0 means no limit)
   setting_cache_formatted_text: Cache formatted text
   setting_default_notification_option: Default notification option
   setting_commit_logtime_enabled: Enable time logging
diff --git a/config/settings.yml b/config/settings.yml
index 667a4427f..ae863f923 100644
--- a/config/settings.yml
+++ b/config/settings.yml
@@ -340,6 +340,10 @@ rest_api_enabled:
 jsonp_enabled:
   default: 0
   security_notifications: 1
+rest_api_rate_limit:
+  format: int
+  default: 0
+  security_notifications: 1
 webhooks_enabled:
   default: 0
   security_notifications: 1
diff --git a/lib/redmine/api_rate_limiter.rb b/lib/redmine/api_rate_limiter.rb
new file mode 100644
index 000000000..85e5ef98d
--- /dev/null
+++ b/lib/redmine/api_rate_limiter.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+# Redmine - project management software
+# Copyright (C) 2006-  Jean-Philippe Lang
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+
+module Redmine
+  # Fixed-window rate limiter for REST API requests.
+  #
+  # Requests are counted per caller (an opaque "bucket" string) in fixed
+  # windows of WINDOW seconds. Counters live in a cache store (Rails.cache
+  # by default), so no database schema change is required.
+  #
+  # The store is injectable, mainly for the tests: the test environment is
+  # configured with the :null_store cache (config/environments/test.rb),
+  # which does not keep counters.
+  module ApiRateLimiter
+    # Window length in seconds
+    WINDOW = 60
+
+    class << self
+      attr_writer :store
+
+      # The cache store holding the request counters
+      def store
+        @store ||= Rails.cache
+      end
+
+      # Increments the request counter of +bucket+ and returns true when
+      # more than +limit+ requests were made within the current window,
+      # false otherwise. A +limit+ of zero disables throttling.
+      #
+      # Fails open: with a store that does not support atomic counters
+      # (e.g. the :null_store) no request is ever throttled.
+      def throttled?(bucket, limit)
+        return false unless limit > 0
+
+        count = store.increment(counter_key(bucket), 1, :expires_in => WINDOW * 2)
+        !count.nil? && count > limit
+      end
+
+      # Number of seconds until the current window ends, suitable for the
+      # Retry-After response header
+      def retry_after
+        WINDOW - (Time.now.to_i % WINDOW)
+      end
+
+      private
+
+      def counter_key(bucket)
+        "api_rate_limiter/#{bucket}/#{Time.now.to_i / WINDOW}"
+      end
+    end
+  end
+end
diff --git a/test/integration/api_test/rate_limiting_test.rb b/test/integration/api_test/rate_limiting_test.rb
new file mode 100644
index 000000000..ea47f82db
--- /dev/null
+++ b/test/integration/api_test/rate_limiting_test.rb
@@ -0,0 +1,124 @@
+# frozen_string_literal: true
+
+# Redmine - project management software
+# Copyright (C) 2006-  Jean-Philippe Lang
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+
+require_relative '../../test_helper'
+
+class Redmine::ApiTest::RateLimitingTest < Redmine::ApiTest::Base
+  def setup
+    super
+    # The test environment runs with the :null_store cache, which does not
+    # keep counters, so give the limiter a real in-memory store
+    Redmine::ApiRateLimiter.store = ActiveSupport::Cache::MemoryStore.new
+  end
+
+  def teardown
+    super
+    Redmine::ApiRateLimiter.store = nil
+    User.current = nil
+  end
+
+  def test_api_requests_within_the_limit_should_succeed
+    with_settings :rest_api_rate_limit => '3' do
+      3.times do
+        get '/users/current.json', :headers => credentials('jsmith')
+
+        assert_response :ok
+      end
+    end
+  end
+
+  def test_api_requests_over_the_limit_should_get_429_with_retry_after_header
+    with_settings :rest_api_rate_limit => '2' do
+      2.times do
+        get '/users/current.json', :headers => credentials('jsmith')
+
+        assert_response :ok
+      end
+
+      get '/users/current.json', :headers => credentials('jsmith')
+
+      assert_response :too_many_requests
+      assert_includes 1..60, response.headers['Retry-After'].to_i
+      assert_equal '2', response.headers['X-RateLimit-Limit']
+      assert_equal ['Rate limit exceeded. Please retry later.'], response.parsed_body['errors']
+    end
+  end
+
+  def test_over_the_limit_response_should_be_available_in_xml
+    with_settings :rest_api_rate_limit => '1' do
+      get '/users/current.xml', :headers => credentials('jsmith')
+
+      assert_response :ok
+
+      get '/users/current.xml', :headers => credentials('jsmith')
+
+      assert_response :too_many_requests
+      assert_equal 'application/xml', response.media_type
+      assert_select 'errors error', :text => 'Rate limit exceeded. Please retry later.'
+    end
+  end
+
+  def test_rate_limit_should_be_counted_per_user
+    with_settings :rest_api_rate_limit => '1' do
+      get '/users/current.json', :headers => credentials('jsmith')
+
+      assert_response :ok
+
+      get '/users/current.json', :headers => credentials('jsmith')
+
+      assert_response :too_many_requests
+
+      get '/users/current.json', :headers => credentials('admin')
+
+      assert_response :ok
+    end
+  end
+
+  def test_anonymous_api_requests_should_be_limited_by_ip
+    with_settings :rest_api_rate_limit => '1' do
+      get '/issues.json'
+
+      assert_response :ok
+
+      get '/issues.json'
+
+      assert_response :too_many_requests
+    end
+  end
+
+  def test_rate_limit_should_not_apply_when_disabled
+    with_settings :rest_api_rate_limit => '0' do
+      5.times do
+        get '/users/current.json', :headers => credentials('jsmith')
+
+        assert_response :ok
+      end
+    end
+  end
+
+  def test_rate_limit_should_not_apply_to_web_ui_requests
+    with_settings :rest_api_rate_limit => '1' do
+      3.times do
+        get '/login'
+
+        assert_response :ok
+      end
+    end
+  end
+end
diff --git a/test/unit/lib/redmine/api_rate_limiter_test.rb b/test/unit/lib/redmine/api_rate_limiter_test.rb
new file mode 100644
index 000000000..0bdbaf904
--- /dev/null
+++ b/test/unit/lib/redmine/api_rate_limiter_test.rb
@@ -0,0 +1,76 @@
+# frozen_string_literal: true
+
+# Redmine - project management software
+# Copyright (C) 2006-  Jean-Philippe Lang
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+
+require_relative '../../../test_helper'
+
+class Redmine::ApiRateLimiterTest < ActiveSupport::TestCase
+  def setup
+    Redmine::ApiRateLimiter.store = ActiveSupport::Cache::MemoryStore.new
+  end
+
+  def teardown
+    Redmine::ApiRateLimiter.store = nil
+  end
+
+  def test_throttled_should_be_false_within_the_limit
+    3.times do
+      assert_not Redmine::ApiRateLimiter.throttled?('user/1', 3)
+    end
+  end
+
+  def test_throttled_should_be_true_above_the_limit
+    3.times {Redmine::ApiRateLimiter.throttled?('user/1', 3)}
+
+    assert Redmine::ApiRateLimiter.throttled?('user/1', 3)
+  end
+
+  def test_buckets_should_be_counted_independently
+    3.times {Redmine::ApiRateLimiter.throttled?('user/1', 3)}
+
+    assert Redmine::ApiRateLimiter.throttled?('user/1', 3)
+    assert_not Redmine::ApiRateLimiter.throttled?('user/2', 3)
+    assert_not Redmine::ApiRateLimiter.throttled?('ip/127.0.0.1', 3)
+  end
+
+  def test_zero_limit_should_disable_throttling
+    5.times do
+      assert_not Redmine::ApiRateLimiter.throttled?('user/1', 0)
+    end
+  end
+
+  def test_counters_should_reset_when_the_window_ends
+    4.times {Redmine::ApiRateLimiter.throttled?('user/1', 3)}
+
+    assert Redmine::ApiRateLimiter.throttled?('user/1', 3)
+    travel Redmine::ApiRateLimiter::WINDOW.seconds do
+      assert_not Redmine::ApiRateLimiter.throttled?('user/1', 3)
+    end
+  end
+
+  def test_throttled_should_fail_open_when_the_store_does_not_count
+    Redmine::ApiRateLimiter.store = ActiveSupport::Cache::NullStore.new
+    5.times do
+      assert_not Redmine::ApiRateLimiter.throttled?('user/1', 1)
+    end
+  end
+
+  def test_retry_after_should_be_within_the_current_window
+    assert_includes 1..Redmine::ApiRateLimiter::WINDOW, Redmine::ApiRateLimiter.retry_after
+  end
+end
-- 
2.54.0

