Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions config/initializers/rack_attack.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,52 @@
# frozen_string_literal: true

require 'json'

positive_integer = lambda do |name, default|
value = ENV.fetch(name, default).to_i
value.positive? ? value : default
end
boolean_type = ActiveModel::Type::Boolean.new

Rack::Attack.throttled_responder = lambda do |request|
match_data = request.env.fetch('rack.attack.match_data')
retry_after = match_data[:period] - (match_data[:epoch_time] % match_data[:period])

if match_data[:count] == match_data[:limit] + 1
Rails.logger.warn(
"Rate limit exceeded: throttle=#{request.env.fetch('rack.attack.matched')} " \
"method=#{request.request_method} path=#{request.path} " \
"limit=#{match_data[:limit]} period=#{match_data[:period]}"
)
end

body = JSON.generate(
errors: [
{ status: 429, resource: request.path, message: 'errors.api.too_many_requests' }
]
)

[
429,
{
'content-type' => 'application/json; charset=utf-8',
'retry-after' => retry_after.to_s
},
[body]
]
end

Rack::Attack.throttle(
'API requests',
limit: ->(_request) { positive_integer.call('PRIMERO_API_RATE_LIMIT_REQUESTS', 300) },
period: ->(_request) { positive_integer.call('PRIMERO_API_RATE_LIMIT_PERIOD', 60) }
) do |request|
enabled = boolean_type.cast(ENV.fetch('PRIMERO_API_RATE_LIMIT_ENABLED', false))
next unless enabled && request.path.match?(%r{\A/api/v2(?:/|\z)})

request.remote_ip
end

# This will return HTTP 429 once the rate limit is exceeded

# 6 login attempts per user name per minute
Expand Down
1 change: 1 addition & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1029,6 +1029,7 @@ en:
errors:
api:
internal_server: Server Error
too_many_requests: Too many requests. Please try again later.
user:
disposable_email: "Looks like this is a temporary email provider. To keep accounts secure, please use your personal or work email."
captcha_service_unavailable: "Verification service is currently unavailable. Please try submitting again: refreshing the page usually helps if the connection was interrupted."
Expand Down
1 change: 1 addition & 0 deletions config/locales/hu.yml
Original file line number Diff line number Diff line change
Expand Up @@ -879,6 +879,7 @@ hu:
errors:
api:
internal_server: Szerver hiba
too_many_requests: Túl sok kérés. Kérjük, próbálja újra később.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For future reference, we maintain non-English translations for application strings in Transifex. I'll add this particular one manually, but you can review the Hungarian ones here: https://app.transifex.com/primero-v2/primero-app-v2/language/hu/

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for helping out on this one

attachments:
maximum: Elérte a rekordhoz tartozó maximum mellékletek számát.
error_loading: Hiba a rekord(ok) betöltésekor
Expand Down
3 changes: 3 additions & 0 deletions docker/defaults.env
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ RAILS_LOG_PATH=/srv/primero/application/log/primero
RAILS_PUBLIC_FILE_SERVER=false

PRIMERO_CONFIGURATION_RUN_SCRIPTS=true
PRIMERO_API_RATE_LIMIT_ENABLED=false
PRIMERO_API_RATE_LIMIT_REQUESTS=300
PRIMERO_API_RATE_LIMIT_PERIOD=60

SOLR_HEAP_MEMORY=512m
SOLR_HOSTNAME=solr
Expand Down
127 changes: 127 additions & 0 deletions spec/middleware/rack_attack_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# frozen_string_literal: true

require 'rails_helper'

describe 'Rack::Attack API rate limit' do
let(:app) { ->(_env) { [200, {}, ['OK']] } }
let(:client) { Rack::MockRequest.new(Rack::Attack.new(app)) }
let(:environment_variables) do
%w[
PRIMERO_API_RATE_LIMIT_ENABLED
PRIMERO_API_RATE_LIMIT_REQUESTS
PRIMERO_API_RATE_LIMIT_PERIOD
]
end

around do |example|
original_values = ENV.to_h.slice(*environment_variables)
environment_variables.each { |name| ENV.delete(name) }
Rack::Attack.reset!

example.run
ensure
environment_variables.each { |name| ENV.delete(name) }
original_values.each { |name, value| ENV[name] = value }
Rack::Attack.reset!
end

it 'is disabled when the enable environment variable is missing' do
ENV['PRIMERO_API_RATE_LIMIT_REQUESTS'] = '1'

2.times do
expect(get('/api/v2/cases', session: 'session-one')).to eq(200)
end
end

it 'uses 300 requests and 60 seconds as defaults' do
throttle = Rack::Attack.throttles.fetch('API requests')

expect(throttle.limit.call(nil)).to eq(300)
expect(throttle.period.call(nil)).to eq(60)
end

it 'throttles API requests when enabled' do
enable_rate_limit(requests: 2)

expect(get('/api/v2/cases', session: 'session-one')).to eq(200)
expect(post('/api/v2/users', session: 'session-one')).to eq(200)
expect(get('/api/v2/cases', session: 'session-one')).to eq(429)
end

it 'returns a JSON error response and logs the first rejected request' do
enable_rate_limit(requests: 1)
expect(get('/api/v2/cases', session: 'session-one')).to eq(200)
expect(Rails.logger).to receive(:warn).with(
'Rate limit exceeded: throttle=API requests method=GET path=/api/v2/cases limit=1 period=60'
).once

response = get_response('/api/v2/cases', session: 'session-one')
error = JSON.parse(response.body).fetch('errors').first

expect(response.status).to eq(429)
expect(response.content_type).to eq('application/json; charset=utf-8')
expect(response['retry-after'].to_i).to be_between(1, 60)
expect(error).to eq(
'status' => 429,
'resource' => '/api/v2/cases',
'message' => 'errors.api.too_many_requests'
)

expect(get('/api/v2/cases', session: 'session-one')).to eq(429)
end

it 'uses the same limit for separate sessions from the same IP address' do
enable_rate_limit(requests: 1)

expect(get('/api/v2/cases', session: 'session-one')).to eq(200)
expect(get('/api/v2/cases', session: 'session-two')).to eq(429)
end

it 'uses the same limit for separate authorization credentials from the same IP address' do
enable_rate_limit(requests: 1)

expect(get('/api/v2/cases', authorization: 'Bearer token-one')).to eq(200)
expect(get('/api/v2/cases', authorization: 'Bearer token-two')).to eq(429)
end

it 'keeps separate limits for separate IP addresses' do
enable_rate_limit(requests: 1)

expect(get('/api/v2/cases', remote_ip: '127.0.0.1')).to eq(200)
expect(get('/api/v2/cases', remote_ip: '127.0.0.2')).to eq(200)
expect(get('/api/v2/cases', remote_ip: '127.0.0.1')).to eq(429)
end

it 'does not throttle requests outside the API' do
enable_rate_limit(requests: 1)

2.times do
expect(get('/v2/dashboards', session: 'session-one')).to eq(200)
end
end

def enable_rate_limit(requests:)
ENV['PRIMERO_API_RATE_LIMIT_ENABLED'] = 'true'
ENV['PRIMERO_API_RATE_LIMIT_REQUESTS'] = requests.to_s
ENV['PRIMERO_API_RATE_LIMIT_PERIOD'] = '60'
end

def get(path, session: nil, authorization: nil, remote_ip: '127.0.0.1')
get_response(path, session:, authorization:, remote_ip:).status
end

def get_response(path, session: nil, authorization: nil, remote_ip: '127.0.0.1')
client.get(path, request_headers(session:, authorization:, remote_ip:))
end

def post(path, session: nil, authorization: nil, remote_ip: '127.0.0.1')
client.post(path, request_headers(session:, authorization:, remote_ip:)).status
end

def request_headers(session:, authorization:, remote_ip:)
headers = { 'REMOTE_ADDR' => remote_ip }
headers['HTTP_COOKIE'] = "_app_session=#{session}" if session
headers['HTTP_AUTHORIZATION'] = authorization if authorization
headers
end
end
Loading