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