Project

General

Profile

Feature #43881 » 0001-Add-rate-limiting-for-the-REST-API-43881.patch

Iurii Dremov, 2026-07-11 03:28

View differences:

app/controllers/application_controller.rb
61 61
    end
62 62
  end
63 63

  
64
  before_action :session_expiration, :user_setup, :check_if_login_required, :set_localization, :check_password_change, :check_twofa_activation
64
  before_action :session_expiration, :user_setup, :check_api_rate_limit, :check_if_login_required, :set_localization, :check_password_change, :check_twofa_activation
65 65
  after_action :record_project_usage
66 66

  
67 67
  rescue_from ::Unauthorized, :with => :deny_access
......
172 172
    user
173 173
  end
174 174

  
175
  # Rejects the request with 429 Too Many Requests when the API caller
176
  # exceeded Setting.rest_api_rate_limit requests per minute.
177
  # Requests are counted per user when authenticated, per source IP
178
  # otherwise, so that repeated requests with invalid credentials are
179
  # throttled as well. Web UI requests are never throttled.
180
  def check_api_rate_limit
181
    return unless api_request?
182

  
183
    limit = Setting.rest_api_rate_limit.to_i
184
    bucket = User.current.logged? ? "user/#{User.current.id}" : "ip/#{request.remote_ip}"
185
    if Redmine::ApiRateLimiter.throttled?(bucket, limit)
186
      logger.warn("API rate limit exceeded for #{bucket} (#{request.request_method} #{request.path})") if logger
187
      response.headers['Retry-After'] = Redmine::ApiRateLimiter.retry_after.to_s
188
      response.headers['X-RateLimit-Limit'] = limit.to_s
189
      @error_messages = [l(:error_api_rate_limit_exceeded)]
190
      render :template => 'common/error_messages', :format => [:api], :status => :too_many_requests, :layout => nil
191
    end
192
  end
193

  
175 194
  def autologin_cookie_name
176 195
    Redmine::Configuration['autologin_cookie_name'].presence || 'autologin'
177 196
  end
app/views/settings/_api.html.erb
3 3
<div class="box tabular settings">
4 4
<p><%= setting_check_box :rest_api_enabled %></p>
5 5

  
6
<p><%= setting_text_field :rest_api_rate_limit, :size => 6 %></p>
7

  
6 8
<p><%= setting_check_box :jsonp_enabled %></p>
7 9

  
8 10
<p><%= setting_check_box :webhooks_enabled %></p>
config/locales/en.yml
253 253
  error_attachment_not_found: "Attachment %{name} not found"
254 254
  error_invalid_authenticity_token: "Invalid form authenticity token."
255 255
  error_query_statement_invalid: "An error occurred while executing the query and has been logged. Please report this error to your Redmine administrator."
256
  error_api_rate_limit_exceeded: "Rate limit exceeded. Please retry later."
256 257

  
257 258
  mail_subject_lost_password: "Your %{value} password"
258 259
  mail_body_lost_password: 'To change your password, click on the following link:'
......
493 494
  setting_issue_done_ratio_issue_status: Use the issue status
494 495
  setting_start_of_week: Start calendars on
495 496
  setting_rest_api_enabled: Enable REST web service
497
  setting_rest_api_rate_limit: Maximum number of API requests per minute and user or IP (0 means no limit)
496 498
  setting_cache_formatted_text: Cache formatted text
497 499
  setting_default_notification_option: Default notification option
498 500
  setting_commit_logtime_enabled: Enable time logging
config/settings.yml
340 340
jsonp_enabled:
341 341
  default: 0
342 342
  security_notifications: 1
343
rest_api_rate_limit:
344
  format: int
345
  default: 0
346
  security_notifications: 1
343 347
webhooks_enabled:
344 348
  default: 0
345 349
  security_notifications: 1
lib/redmine/api_rate_limiter.rb
1
# frozen_string_literal: true
2

  
3
# Redmine - project management software
4
# Copyright (C) 2006-  Jean-Philippe Lang
5
#
6
# This program is free software; you can redistribute it and/or
7
# modify it under the terms of the GNU General Public License
8
# as published by the Free Software Foundation; either version 2
9
# of the License, or (at your option) any later version.
10
#
11
# This program is distributed in the hope that it will be useful,
12
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
# GNU General Public License for more details.
15
#
16
# You should have received a copy of the GNU General Public License
17
# along with this program; if not, write to the Free Software
18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19

  
20
module Redmine
21
  # Fixed-window rate limiter for REST API requests.
22
  #
23
  # Requests are counted per caller (an opaque "bucket" string) in fixed
24
  # windows of WINDOW seconds. Counters live in a cache store (Rails.cache
25
  # by default), so no database schema change is required.
26
  #
27
  # The store is injectable, mainly for the tests: the test environment is
28
  # configured with the :null_store cache (config/environments/test.rb),
29
  # which does not keep counters.
30
  module ApiRateLimiter
31
    # Window length in seconds
32
    WINDOW = 60
33

  
34
    class << self
35
      attr_writer :store
36

  
37
      # The cache store holding the request counters
38
      def store
39
        @store ||= Rails.cache
40
      end
41

  
42
      # Increments the request counter of +bucket+ and returns true when
43
      # more than +limit+ requests were made within the current window,
44
      # false otherwise. A +limit+ of zero disables throttling.
45
      #
46
      # Fails open: with a store that does not support atomic counters
47
      # (e.g. the :null_store) no request is ever throttled.
48
      def throttled?(bucket, limit)
49
        return false unless limit > 0
50

  
51
        count = store.increment(counter_key(bucket), 1, :expires_in => WINDOW * 2)
52
        !count.nil? && count > limit
53
      end
54

  
55
      # Number of seconds until the current window ends, suitable for the
56
      # Retry-After response header
57
      def retry_after
58
        WINDOW - (Time.now.to_i % WINDOW)
59
      end
60

  
61
      private
62

  
63
      def counter_key(bucket)
64
        "api_rate_limiter/#{bucket}/#{Time.now.to_i / WINDOW}"
65
      end
66
    end
67
  end
68
end
test/integration/api_test/rate_limiting_test.rb
1
# frozen_string_literal: true
2

  
3
# Redmine - project management software
4
# Copyright (C) 2006-  Jean-Philippe Lang
5
#
6
# This program is free software; you can redistribute it and/or
7
# modify it under the terms of the GNU General Public License
8
# as published by the Free Software Foundation; either version 2
9
# of the License, or (at your option) any later version.
10
#
11
# This program is distributed in the hope that it will be useful,
12
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
# GNU General Public License for more details.
15
#
16
# You should have received a copy of the GNU General Public License
17
# along with this program; if not, write to the Free Software
18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19

  
20
require_relative '../../test_helper'
21

  
22
class Redmine::ApiTest::RateLimitingTest < Redmine::ApiTest::Base
23
  def setup
24
    super
25
    # The test environment runs with the :null_store cache, which does not
26
    # keep counters, so give the limiter a real in-memory store
27
    Redmine::ApiRateLimiter.store = ActiveSupport::Cache::MemoryStore.new
28
  end
29

  
30
  def teardown
31
    super
32
    Redmine::ApiRateLimiter.store = nil
33
    User.current = nil
34
  end
35

  
36
  def test_api_requests_within_the_limit_should_succeed
37
    with_settings :rest_api_rate_limit => '3' do
38
      3.times do
39
        get '/users/current.json', :headers => credentials('jsmith')
40

  
41
        assert_response :ok
42
      end
43
    end
44
  end
45

  
46
  def test_api_requests_over_the_limit_should_get_429_with_retry_after_header
47
    with_settings :rest_api_rate_limit => '2' do
48
      2.times do
49
        get '/users/current.json', :headers => credentials('jsmith')
50

  
51
        assert_response :ok
52
      end
53

  
54
      get '/users/current.json', :headers => credentials('jsmith')
55

  
56
      assert_response :too_many_requests
57
      assert_includes 1..60, response.headers['Retry-After'].to_i
58
      assert_equal '2', response.headers['X-RateLimit-Limit']
59
      assert_equal ['Rate limit exceeded. Please retry later.'], response.parsed_body['errors']
60
    end
61
  end
62

  
63
  def test_over_the_limit_response_should_be_available_in_xml
64
    with_settings :rest_api_rate_limit => '1' do
65
      get '/users/current.xml', :headers => credentials('jsmith')
66

  
67
      assert_response :ok
68

  
69
      get '/users/current.xml', :headers => credentials('jsmith')
70

  
71
      assert_response :too_many_requests
72
      assert_equal 'application/xml', response.media_type
73
      assert_select 'errors error', :text => 'Rate limit exceeded. Please retry later.'
74
    end
75
  end
76

  
77
  def test_rate_limit_should_be_counted_per_user
78
    with_settings :rest_api_rate_limit => '1' do
79
      get '/users/current.json', :headers => credentials('jsmith')
80

  
81
      assert_response :ok
82

  
83
      get '/users/current.json', :headers => credentials('jsmith')
84

  
85
      assert_response :too_many_requests
86

  
87
      get '/users/current.json', :headers => credentials('admin')
88

  
89
      assert_response :ok
90
    end
91
  end
92

  
93
  def test_anonymous_api_requests_should_be_limited_by_ip
94
    with_settings :rest_api_rate_limit => '1' do
95
      get '/issues.json'
96

  
97
      assert_response :ok
98

  
99
      get '/issues.json'
100

  
101
      assert_response :too_many_requests
102
    end
103
  end
104

  
105
  def test_rate_limit_should_not_apply_when_disabled
106
    with_settings :rest_api_rate_limit => '0' do
107
      5.times do
108
        get '/users/current.json', :headers => credentials('jsmith')
109

  
110
        assert_response :ok
111
      end
112
    end
113
  end
114

  
115
  def test_rate_limit_should_not_apply_to_web_ui_requests
116
    with_settings :rest_api_rate_limit => '1' do
117
      3.times do
118
        get '/login'
119

  
120
        assert_response :ok
121
      end
122
    end
123
  end
124
end
test/unit/lib/redmine/api_rate_limiter_test.rb
1
# frozen_string_literal: true
2

  
3
# Redmine - project management software
4
# Copyright (C) 2006-  Jean-Philippe Lang
5
#
6
# This program is free software; you can redistribute it and/or
7
# modify it under the terms of the GNU General Public License
8
# as published by the Free Software Foundation; either version 2
9
# of the License, or (at your option) any later version.
10
#
11
# This program is distributed in the hope that it will be useful,
12
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
# GNU General Public License for more details.
15
#
16
# You should have received a copy of the GNU General Public License
17
# along with this program; if not, write to the Free Software
18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19

  
20
require_relative '../../../test_helper'
21

  
22
class Redmine::ApiRateLimiterTest < ActiveSupport::TestCase
23
  def setup
24
    Redmine::ApiRateLimiter.store = ActiveSupport::Cache::MemoryStore.new
25
  end
26

  
27
  def teardown
28
    Redmine::ApiRateLimiter.store = nil
29
  end
30

  
31
  def test_throttled_should_be_false_within_the_limit
32
    3.times do
33
      assert_not Redmine::ApiRateLimiter.throttled?('user/1', 3)
34
    end
35
  end
36

  
37
  def test_throttled_should_be_true_above_the_limit
38
    3.times {Redmine::ApiRateLimiter.throttled?('user/1', 3)}
39

  
40
    assert Redmine::ApiRateLimiter.throttled?('user/1', 3)
41
  end
42

  
43
  def test_buckets_should_be_counted_independently
44
    3.times {Redmine::ApiRateLimiter.throttled?('user/1', 3)}
45

  
46
    assert Redmine::ApiRateLimiter.throttled?('user/1', 3)
47
    assert_not Redmine::ApiRateLimiter.throttled?('user/2', 3)
48
    assert_not Redmine::ApiRateLimiter.throttled?('ip/127.0.0.1', 3)
49
  end
50

  
51
  def test_zero_limit_should_disable_throttling
52
    5.times do
53
      assert_not Redmine::ApiRateLimiter.throttled?('user/1', 0)
54
    end
55
  end
56

  
57
  def test_counters_should_reset_when_the_window_ends
58
    4.times {Redmine::ApiRateLimiter.throttled?('user/1', 3)}
59

  
60
    assert Redmine::ApiRateLimiter.throttled?('user/1', 3)
61
    travel Redmine::ApiRateLimiter::WINDOW.seconds do
62
      assert_not Redmine::ApiRateLimiter.throttled?('user/1', 3)
63
    end
64
  end
65

  
66
  def test_throttled_should_fail_open_when_the_store_does_not_count
67
    Redmine::ApiRateLimiter.store = ActiveSupport::Cache::NullStore.new
68
    5.times do
69
      assert_not Redmine::ApiRateLimiter.throttled?('user/1', 1)
70
    end
71
  end
72

  
73
  def test_retry_after_should_be_within_the_current_window
74
    assert_includes 1..Redmine::ApiRateLimiter::WINDOW, Redmine::ApiRateLimiter.retry_after
75
  end
76
end
(1-1/2)