From 05cea9cbd88bc8e8481b73dae24a3a077260969c 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, using the rate limiting provided by Action Controller. Requests are counted per user when authenticated, per source IP otherwise. Disabled by default.
---
 app/controllers/application_controller.rb     |  33 ++++-
 app/views/settings/_api.html.erb              |   2 +
 config/locales/en.yml                         |   2 +
 config/settings.yml                           |   4 +
 .../api_test/rate_limiting_test.rb            | 135 ++++++++++++++++++
 5 files changed, 175 insertions(+), 1 deletion(-)
 create mode 100644 test/integration/api_test/rate_limiting_test.rb

diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 5b389ddc8..8f2baa89e 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -30,6 +30,9 @@ class ApplicationController < ActionController::Base
   include AvatarsHelper
   include IconsHelper
 
+  # Length of the window used to rate limit the API, in seconds
+  API_RATE_LIMIT_WINDOW = 60
+
   helper :routes
   helper :avatars
   helper :icons
@@ -61,7 +64,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 +175,34 @@ class ApplicationController < ActionController::Base
     user
   end
 
+  # Rate limits the API to Setting.rest_api_rate_limit requests per minute,
+  # using the rate limiting provided by Action Controller. 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
+    limit = Setting.rest_api_rate_limit.to_i
+    return unless api_request? && limit > 0
+
+    rate_limiting(
+      :to => limit, :within => API_RATE_LIMIT_WINDOW.seconds,
+      :scope => 'api', :name => nil,
+      :by => lambda {User.current.logged? ? "user/#{User.current.id}" : "ip/#{request.remote_ip}"},
+      :with => lambda {render_api_rate_limit_exceeded(limit)},
+      :store => Rails.cache
+    )
+  end
+
+  # Renders a 429 Too Many Requests response for an API request that
+  # exceeded the rate limit
+  def render_api_rate_limit_exceeded(limit)
+    logger.warn("API rate limit exceeded (#{request.request_method} #{request.path})") if logger
+    response.headers['Retry-After'] = API_RATE_LIMIT_WINDOW.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
+
   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/test/integration/api_test/rate_limiting_test.rb b/test/integration/api_test/rate_limiting_test.rb
new file mode 100644
index 000000000..409694aee
--- /dev/null
+++ b/test/integration/api_test/rate_limiting_test.rb
@@ -0,0 +1,135 @@
+# 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 is configured with the :null_store cache, which
+    # does not keep the request counters, so use a real store instead
+    Rails.stubs(:cache).returns(ActiveSupport::Cache::MemoryStore.new)
+  end
+
+  def teardown
+    super
+    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_rate_limit_headers
+    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_equal '60', response.headers['Retry-After']
+      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_rate_limit_should_be_shared_by_all_api_controllers
+    with_settings :rest_api_rate_limit => '1' do
+      get '/users/current.json', :headers => credentials('jsmith')
+
+      assert_response :ok
+
+      get '/issues.json', :headers => credentials('jsmith')
+
+      assert_response :too_many_requests
+    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
-- 
2.54.0

