Feature #43881 » 0001-Add-rate-limiting-for-the-REST-API-43881.patch
| 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 |
|