Project

General

Profile

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

Iurii Dremov, 2026-07-12 01:06

View differences:

app/controllers/application_controller.rb
30 30
  include AvatarsHelper
31 31
  include IconsHelper
32 32

  
33
  # Length of the window used to rate limit the API, in seconds
34
  API_RATE_LIMIT_WINDOW = 60
35

  
33 36
  helper :routes
34 37
  helper :avatars
35 38
  helper :icons
......
61 64
    end
62 65
  end
63 66

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

  
67 70
  rescue_from ::Unauthorized, :with => :deny_access
......
172 175
    user
173 176
  end
174 177

  
178
  # Rate limits the API to Setting.rest_api_rate_limit requests per minute,
179
  # using the rate limiting provided by Action Controller. Requests are
180
  # counted per user when authenticated, per source IP otherwise, so that
181
  # repeated requests with invalid credentials are throttled as well.
182
  # Web UI requests are never throttled.
183
  def check_api_rate_limit
184
    limit = Setting.rest_api_rate_limit.to_i
185
    return unless api_request? && limit > 0
186

  
187
    rate_limiting(
188
      :to => limit, :within => API_RATE_LIMIT_WINDOW.seconds,
189
      :scope => 'api', :name => nil,
190
      :by => lambda {User.current.logged? ? "user/#{User.current.id}" : "ip/#{request.remote_ip}"},
191
      :with => lambda {render_api_rate_limit_exceeded(limit)},
192
      :store => Rails.cache
193
    )
194
  end
195

  
196
  # Renders a 429 Too Many Requests response for an API request that
197
  # exceeded the rate limit
198
  def render_api_rate_limit_exceeded(limit)
199
    logger.warn("API rate limit exceeded (#{request.request_method} #{request.path})") if logger
200
    response.headers['Retry-After'] = API_RATE_LIMIT_WINDOW.to_s
201
    response.headers['X-RateLimit-Limit'] = limit.to_s
202
    @error_messages = [l(:error_api_rate_limit_exceeded)]
203
    render :template => 'common/error_messages', :format => [:api], :status => :too_many_requests, :layout => nil
204
  end
205

  
175 206
  def autologin_cookie_name
176 207
    Redmine::Configuration['autologin_cookie_name'].presence || 'autologin'
177 208
  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
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 is configured with the :null_store cache, which
26
    # does not keep the request counters, so use a real store instead
27
    Rails.stubs(:cache).returns(ActiveSupport::Cache::MemoryStore.new)
28
  end
29

  
30
  def teardown
31
    super
32
    User.current = nil
33
  end
34

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

  
40
        assert_response :ok
41
      end
42
    end
43
  end
44

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

  
50
        assert_response :ok
51
      end
52

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

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

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

  
66
      assert_response :ok
67

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

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

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

  
80
      assert_response :ok
81

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

  
84
      assert_response :too_many_requests
85

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

  
88
      assert_response :ok
89
    end
90
  end
91

  
92
  def test_rate_limit_should_be_shared_by_all_api_controllers
93
    with_settings :rest_api_rate_limit => '1' do
94
      get '/users/current.json', :headers => credentials('jsmith')
95

  
96
      assert_response :ok
97

  
98
      get '/issues.json', :headers => credentials('jsmith')
99

  
100
      assert_response :too_many_requests
101
    end
102
  end
103

  
104
  def test_anonymous_api_requests_should_be_limited_by_ip
105
    with_settings :rest_api_rate_limit => '1' do
106
      get '/issues.json'
107

  
108
      assert_response :ok
109

  
110
      get '/issues.json'
111

  
112
      assert_response :too_many_requests
113
    end
114
  end
115

  
116
  def test_rate_limit_should_not_apply_when_disabled
117
    with_settings :rest_api_rate_limit => '0' do
118
      5.times do
119
        get '/users/current.json', :headers => credentials('jsmith')
120

  
121
        assert_response :ok
122
      end
123
    end
124
  end
125

  
126
  def test_rate_limit_should_not_apply_to_web_ui_requests
127
    with_settings :rest_api_rate_limit => '1' do
128
      3.times do
129
        get '/login'
130

  
131
        assert_response :ok
132
      end
133
    end
134
  end
135
end
(2-2/2)