Commit 371180a4 authored by Douwe Maan's avatar Douwe Maan

Merge branch 'mk-add-user-rate-limits' into 'master'

Add request rate limits

Closes #30053

See merge request gitlab-org/gitlab-ce!14708
parents 7142af49 4188c10c
...@@ -11,8 +11,7 @@ class ApplicationController < ActionController::Base ...@@ -11,8 +11,7 @@ class ApplicationController < ActionController::Base
include EnforcesTwoFactorAuthentication include EnforcesTwoFactorAuthentication
include WithPerformanceBar include WithPerformanceBar
before_action :authenticate_user_from_personal_access_token! before_action :authenticate_sessionless_user!
before_action :authenticate_user_from_rss_token!
before_action :authenticate_user! before_action :authenticate_user!
before_action :validate_user_service_ticket! before_action :validate_user_service_ticket!
before_action :check_password_expiration before_action :check_password_expiration
...@@ -100,27 +99,11 @@ class ApplicationController < ActionController::Base ...@@ -100,27 +99,11 @@ class ApplicationController < ActionController::Base
return try(:authenticated_user) return try(:authenticated_user)
end end
def authenticate_user_from_personal_access_token! # This filter handles personal access tokens, and atom requests with rss tokens
token = params[:private_token].presence || request.headers['PRIVATE-TOKEN'].presence def authenticate_sessionless_user!
user = Gitlab::Auth::RequestAuthenticator.new(request).find_sessionless_user
return unless token.present? sessionless_sign_in(user) if user
user = User.find_by_personal_access_token(token)
sessionless_sign_in(user)
end
# This filter handles authentication for atom request with an rss_token
def authenticate_user_from_rss_token!
return unless request.format.atom?
token = params[:rss_token].presence
return unless token.present?
user = User.find_by_rss_token(token)
sessionless_sign_in(user)
end end
def log_exception(exception) def log_exception(exception)
......
...@@ -231,6 +231,15 @@ module ApplicationSettingsHelper ...@@ -231,6 +231,15 @@ module ApplicationSettingsHelper
:sign_in_text, :sign_in_text,
:signup_enabled, :signup_enabled,
:terminal_max_session_time, :terminal_max_session_time,
:throttle_unauthenticated_enabled,
:throttle_unauthenticated_requests_per_period,
:throttle_unauthenticated_period_in_seconds,
:throttle_authenticated_web_enabled,
:throttle_authenticated_web_requests_per_period,
:throttle_authenticated_web_period_in_seconds,
:throttle_authenticated_api_enabled,
:throttle_authenticated_api_requests_per_period,
:throttle_authenticated_api_period_in_seconds,
:two_factor_grace_period, :two_factor_grace_period,
:unique_ips_limit_enabled, :unique_ips_limit_enabled,
:unique_ips_limit_per_user, :unique_ips_limit_per_user,
......
...@@ -295,6 +295,15 @@ class ApplicationSetting < ActiveRecord::Base ...@@ -295,6 +295,15 @@ class ApplicationSetting < ActiveRecord::Base
sign_in_text: nil, sign_in_text: nil,
signup_enabled: Settings.gitlab['signup_enabled'], signup_enabled: Settings.gitlab['signup_enabled'],
terminal_max_session_time: 0, terminal_max_session_time: 0,
throttle_unauthenticated_enabled: false,
throttle_unauthenticated_requests_per_period: 3600,
throttle_unauthenticated_period_in_seconds: 3600,
throttle_authenticated_web_enabled: false,
throttle_authenticated_web_requests_per_period: 7200,
throttle_authenticated_web_period_in_seconds: 3600,
throttle_authenticated_api_enabled: false,
throttle_authenticated_api_requests_per_period: 7200,
throttle_authenticated_api_period_in_seconds: 3600,
two_factor_grace_period: 48, two_factor_grace_period: 48,
user_default_external: false, user_default_external: false,
polling_interval_multiplier: 1, polling_interval_multiplier: 1,
......
...@@ -743,5 +743,56 @@ ...@@ -743,5 +743,56 @@
installations. Set to 0 to completely disable polling. installations. Set to 0 to completely disable polling.
= link_to icon('question-circle'), help_page_path('administration/polling') = link_to icon('question-circle'), help_page_path('administration/polling')
%fieldset
%legend User and IP Rate Limits
.form-group
.col-sm-offset-2.col-sm-10
.checkbox
= f.label :throttle_unauthenticated_enabled do
= f.check_box :throttle_unauthenticated_enabled
Enable unauthenticated request rate limit
%span.help-block
Helps reduce request volume (e.g. from crawlers or abusive bots)
.form-group
= f.label :throttle_unauthenticated_requests_per_period, 'Max requests per period per IP', class: 'control-label col-sm-2'
.col-sm-10
= f.number_field :throttle_unauthenticated_requests_per_period, class: 'form-control'
.form-group
= f.label :throttle_unauthenticated_period_in_seconds, 'Rate limit period in seconds', class: 'control-label col-sm-2'
.col-sm-10
= f.number_field :throttle_unauthenticated_period_in_seconds, class: 'form-control'
.form-group
.col-sm-offset-2.col-sm-10
.checkbox
= f.label :throttle_authenticated_api_enabled do
= f.check_box :throttle_authenticated_api_enabled
Enable authenticated API request rate limit
%span.help-block
Helps reduce request volume (e.g. from crawlers or abusive bots)
.form-group
= f.label :throttle_authenticated_api_requests_per_period, 'Max requests per period per user', class: 'control-label col-sm-2'
.col-sm-10
= f.number_field :throttle_authenticated_api_requests_per_period, class: 'form-control'
.form-group
= f.label :throttle_authenticated_api_period_in_seconds, 'Rate limit period in seconds', class: 'control-label col-sm-2'
.col-sm-10
= f.number_field :throttle_authenticated_api_period_in_seconds, class: 'form-control'
.form-group
.col-sm-offset-2.col-sm-10
.checkbox
= f.label :throttle_authenticated_web_enabled do
= f.check_box :throttle_authenticated_web_enabled
Enable authenticated web request rate limit
%span.help-block
Helps reduce request volume (e.g. from crawlers or abusive bots)
.form-group
= f.label :throttle_authenticated_web_requests_per_period, 'Max requests per period per user', class: 'control-label col-sm-2'
.col-sm-10
= f.number_field :throttle_authenticated_web_requests_per_period, class: 'form-control'
.form-group
= f.label :throttle_authenticated_web_period_in_seconds, 'Rate limit period in seconds', class: 'control-label col-sm-2'
.col-sm-10
= f.number_field :throttle_authenticated_web_period_in_seconds, class: 'form-control'
.form-actions .form-actions
= f.submit 'Save', class: 'btn btn-save' = f.submit 'Save', class: 'btn btn-save'
---
title: Add anonymous rate limit per IP, and authenticated (web or API) rate limits
per user
merge_request: 14708
author:
type: added
...@@ -113,7 +113,7 @@ module Gitlab ...@@ -113,7 +113,7 @@ module Gitlab
config.action_view.sanitized_allowed_protocols = %w(smb) config.action_view.sanitized_allowed_protocols = %w(smb)
config.middleware.insert_before Warden::Manager, Rack::Attack config.middleware.insert_after Warden::Manager, Rack::Attack
# Allow access to GitLab API from other domains # Allow access to GitLab API from other domains
config.middleware.insert_before Warden::Manager, Rack::Cors do config.middleware.insert_before Warden::Manager, Rack::Cors do
......
module Gitlab::Throttle
def self.settings
Gitlab::CurrentSettings.current_application_settings
end
def self.unauthenticated_options
limit_proc = proc { |req| settings.throttle_unauthenticated_requests_per_period }
period_proc = proc { |req| settings.throttle_unauthenticated_period_in_seconds.seconds }
{ limit: limit_proc, period: period_proc }
end
def self.authenticated_api_options
limit_proc = proc { |req| settings.throttle_authenticated_api_requests_per_period }
period_proc = proc { |req| settings.throttle_authenticated_api_period_in_seconds.seconds }
{ limit: limit_proc, period: period_proc }
end
def self.authenticated_web_options
limit_proc = proc { |req| settings.throttle_authenticated_web_requests_per_period }
period_proc = proc { |req| settings.throttle_authenticated_web_period_in_seconds.seconds }
{ limit: limit_proc, period: period_proc }
end
end
class Rack::Attack
throttle('throttle_unauthenticated', Gitlab::Throttle.unauthenticated_options) do |req|
Gitlab::Throttle.settings.throttle_unauthenticated_enabled &&
req.unauthenticated? &&
req.ip
end
throttle('throttle_authenticated_api', Gitlab::Throttle.authenticated_api_options) do |req|
Gitlab::Throttle.settings.throttle_authenticated_api_enabled &&
req.api_request? &&
req.authenticated_user_id
end
throttle('throttle_authenticated_web', Gitlab::Throttle.authenticated_web_options) do |req|
Gitlab::Throttle.settings.throttle_authenticated_web_enabled &&
req.web_request? &&
req.authenticated_user_id
end
class Request
def unauthenticated?
!authenticated_user_id
end
def authenticated_user_id
Gitlab::Auth::RequestAuthenticator.new(self).user&.id
end
def api_request?
path.start_with?('/api')
end
def web_request?
!api_request?
end
end
end
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
class AddGlobalRateLimitsToApplicationSettings < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
add_column_with_default :application_settings, :throttle_unauthenticated_enabled, :boolean, default: false, allow_null: false
add_column_with_default :application_settings, :throttle_unauthenticated_requests_per_period, :integer, default: 3600, allow_null: false
add_column_with_default :application_settings, :throttle_unauthenticated_period_in_seconds, :integer, default: 3600, allow_null: false
add_column_with_default :application_settings, :throttle_authenticated_api_enabled, :boolean, default: false, allow_null: false
add_column_with_default :application_settings, :throttle_authenticated_api_requests_per_period, :integer, default: 7200, allow_null: false
add_column_with_default :application_settings, :throttle_authenticated_api_period_in_seconds, :integer, default: 3600, allow_null: false
add_column_with_default :application_settings, :throttle_authenticated_web_enabled, :boolean, default: false, allow_null: false
add_column_with_default :application_settings, :throttle_authenticated_web_requests_per_period, :integer, default: 7200, allow_null: false
add_column_with_default :application_settings, :throttle_authenticated_web_period_in_seconds, :integer, default: 3600, allow_null: false
end
def down
remove_column :application_settings, :throttle_authenticated_web_period_in_seconds
remove_column :application_settings, :throttle_authenticated_web_requests_per_period
remove_column :application_settings, :throttle_authenticated_web_enabled
remove_column :application_settings, :throttle_authenticated_api_period_in_seconds
remove_column :application_settings, :throttle_authenticated_api_requests_per_period
remove_column :application_settings, :throttle_authenticated_api_enabled
remove_column :application_settings, :throttle_unauthenticated_period_in_seconds
remove_column :application_settings, :throttle_unauthenticated_requests_per_period
remove_column :application_settings, :throttle_unauthenticated_enabled
end
end
...@@ -140,6 +140,15 @@ ActiveRecord::Schema.define(version: 20171114104051) do ...@@ -140,6 +140,15 @@ ActiveRecord::Schema.define(version: 20171114104051) do
t.integer "circuitbreaker_storage_timeout", default: 30 t.integer "circuitbreaker_storage_timeout", default: 30
t.integer "circuitbreaker_access_retries", default: 3 t.integer "circuitbreaker_access_retries", default: 3
t.integer "circuitbreaker_backoff_threshold", default: 80 t.integer "circuitbreaker_backoff_threshold", default: 80
t.boolean "throttle_unauthenticated_enabled", default: false, null: false
t.integer "throttle_unauthenticated_requests_per_period", default: 3600, null: false
t.integer "throttle_unauthenticated_period_in_seconds", default: 3600, null: false
t.boolean "throttle_authenticated_api_enabled", default: false, null: false
t.integer "throttle_authenticated_api_requests_per_period", default: 7200, null: false
t.integer "throttle_authenticated_api_period_in_seconds", default: 3600, null: false
t.boolean "throttle_authenticated_web_enabled", default: false, null: false
t.integer "throttle_authenticated_web_requests_per_period", default: 7200, null: false
t.integer "throttle_authenticated_web_period_in_seconds", default: 3600, null: false
end end
create_table "audit_events", force: :cascade do |t| create_table "audit_events", force: :cascade do |t|
......
...@@ -6,9 +6,6 @@ module API ...@@ -6,9 +6,6 @@ module API
module APIGuard module APIGuard
extend ActiveSupport::Concern extend ActiveSupport::Concern
PRIVATE_TOKEN_HEADER = "HTTP_PRIVATE_TOKEN".freeze
PRIVATE_TOKEN_PARAM = :private_token
included do |base| included do |base|
# OAuth2 Resource Server Authentication # OAuth2 Resource Server Authentication
use Rack::OAuth2::Server::Resource::Bearer, 'The API' do |request| use Rack::OAuth2::Server::Resource::Bearer, 'The API' do |request|
...@@ -42,7 +39,7 @@ module API ...@@ -42,7 +39,7 @@ module API
# Helper Methods for Grape Endpoint # Helper Methods for Grape Endpoint
module HelperMethods module HelperMethods
include Gitlab::Utils::StrongMemoize include Gitlab::Auth::UserAuthFinders
def find_current_user! def find_current_user!
user = find_user_from_access_token || find_user_from_warden user = find_user_from_access_token || find_user_from_warden
...@@ -53,76 +50,8 @@ module API ...@@ -53,76 +50,8 @@ module API
user user
end end
def access_token
strong_memoize(:access_token) do
find_oauth_access_token || find_personal_access_token
end
end
def validate_access_token!(scopes: [])
return unless access_token
case AccessTokenValidationService.new(access_token, request: request).validate(scopes: scopes)
when AccessTokenValidationService::INSUFFICIENT_SCOPE
raise InsufficientScopeError.new(scopes)
when AccessTokenValidationService::EXPIRED
raise ExpiredError
when AccessTokenValidationService::REVOKED
raise RevokedError
end
end
private private
def find_user_from_access_token
return unless access_token
validate_access_token!
access_token.user || raise(UnauthorizedError)
end
# Check the Rails session for valid authentication details
def find_user_from_warden
warden.try(:authenticate) if verified_request?
end
def warden
env['warden']
end
# Check if the request is GET/HEAD, or if CSRF token is valid.
def verified_request?
Gitlab::RequestForgeryProtection.verified?(env)
end
def find_oauth_access_token
token = Doorkeeper::OAuth::Token.from_request(doorkeeper_request, *Doorkeeper.configuration.access_token_methods)
return unless token
# Expiration, revocation and scopes are verified in `find_user_by_access_token`
access_token = OauthAccessToken.by_token(token)
raise UnauthorizedError unless access_token
access_token.revoke_previous_refresh_token!
access_token
end
def find_personal_access_token
token = (params[PRIVATE_TOKEN_PARAM] || env[PRIVATE_TOKEN_HEADER]).to_s
return unless token.present?
# Expiration, revocation and scopes are verified in `find_user_by_access_token`
access_token = PersonalAccessToken.find_by(token: token)
raise UnauthorizedError unless access_token
access_token
end
def doorkeeper_request
@doorkeeper_request ||= ActionDispatch::Request.new(env)
end
# An array of scopes that were registered (using `allow_access_with_scope`) # An array of scopes that were registered (using `allow_access_with_scope`)
# for the current endpoint class. It also returns scopes registered on # for the current endpoint class. It also returns scopes registered on
# `API::API`, since these are meant to apply to all API routes. # `API::API`, since these are meant to apply to all API routes.
...@@ -145,8 +74,11 @@ module API ...@@ -145,8 +74,11 @@ module API
private private
def install_error_responders(base) def install_error_responders(base)
error_classes = [MissingTokenError, TokenNotFoundError, error_classes = [Gitlab::Auth::MissingTokenError,
ExpiredError, RevokedError, InsufficientScopeError] Gitlab::Auth::TokenNotFoundError,
Gitlab::Auth::ExpiredError,
Gitlab::Auth::RevokedError,
Gitlab::Auth::InsufficientScopeError]
base.__send__(:rescue_from, *error_classes, oauth2_bearer_token_error_handler) # rubocop:disable GitlabSecurity/PublicSend base.__send__(:rescue_from, *error_classes, oauth2_bearer_token_error_handler) # rubocop:disable GitlabSecurity/PublicSend
end end
...@@ -155,25 +87,25 @@ module API ...@@ -155,25 +87,25 @@ module API
proc do |e| proc do |e|
response = response =
case e case e
when MissingTokenError when Gitlab::Auth::MissingTokenError
Rack::OAuth2::Server::Resource::Bearer::Unauthorized.new Rack::OAuth2::Server::Resource::Bearer::Unauthorized.new
when TokenNotFoundError when Gitlab::Auth::TokenNotFoundError
Rack::OAuth2::Server::Resource::Bearer::Unauthorized.new( Rack::OAuth2::Server::Resource::Bearer::Unauthorized.new(
:invalid_token, :invalid_token,
"Bad Access Token.") "Bad Access Token.")
when ExpiredError when Gitlab::Auth::ExpiredError
Rack::OAuth2::Server::Resource::Bearer::Unauthorized.new( Rack::OAuth2::Server::Resource::Bearer::Unauthorized.new(
:invalid_token, :invalid_token,
"Token is expired. You can either do re-authorization or token refresh.") "Token is expired. You can either do re-authorization or token refresh.")
when RevokedError when Gitlab::Auth::RevokedError
Rack::OAuth2::Server::Resource::Bearer::Unauthorized.new( Rack::OAuth2::Server::Resource::Bearer::Unauthorized.new(
:invalid_token, :invalid_token,
"Token was revoked. You have to re-authorize from the user.") "Token was revoked. You have to re-authorize from the user.")
when InsufficientScopeError when Gitlab::Auth::InsufficientScopeError
# FIXME: ForbiddenError (inherited from Bearer::Forbidden of Rack::Oauth2) # FIXME: ForbiddenError (inherited from Bearer::Forbidden of Rack::Oauth2)
# does not include WWW-Authenticate header, which breaks the standard. # does not include WWW-Authenticate header, which breaks the standard.
Rack::OAuth2::Server::Resource::Bearer::Forbidden.new( Rack::OAuth2::Server::Resource::Bearer::Forbidden.new(
...@@ -186,22 +118,5 @@ module API ...@@ -186,22 +118,5 @@ module API
end end
end end
end end
#
# Exceptions
#
MissingTokenError = Class.new(StandardError)
TokenNotFoundError = Class.new(StandardError)
ExpiredError = Class.new(StandardError)
RevokedError = Class.new(StandardError)
UnauthorizedError = Class.new(StandardError)
class InsufficientScopeError < StandardError
attr_reader :scopes
def initialize(scopes)
@scopes = scopes.map { |s| s.try(:name) || s }
end
end
end end
end end
...@@ -398,7 +398,7 @@ module API ...@@ -398,7 +398,7 @@ module API
begin begin
@initial_current_user = Gitlab::Auth::UniqueIpsLimiter.limit_user! { find_current_user! } @initial_current_user = Gitlab::Auth::UniqueIpsLimiter.limit_user! { find_current_user! }
rescue APIGuard::UnauthorizedError rescue Gitlab::Auth::UnauthorizedError
unauthorized! unauthorized!
end end
end end
......
# Use for authentication only, in particular for Rack::Attack.
# Does not perform authorization of scopes, etc.
module Gitlab
module Auth
class RequestAuthenticator
include UserAuthFinders
attr_reader :request
def initialize(request)
@request = request
end
def user
find_sessionless_user || find_user_from_warden
end
def find_sessionless_user
find_user_from_access_token || find_user_from_rss_token
rescue Gitlab::Auth::AuthenticationError
nil
end
end
end
end
module Gitlab
module Auth
#
# Exceptions
#
AuthenticationError = Class.new(StandardError)
MissingTokenError = Class.new(AuthenticationError)
TokenNotFoundError = Class.new(AuthenticationError)
ExpiredError = Class.new(AuthenticationError)
RevokedError = Class.new(AuthenticationError)
UnauthorizedError = Class.new(AuthenticationError)
class InsufficientScopeError < AuthenticationError
attr_reader :scopes
def initialize(scopes)
@scopes = scopes.map { |s| s.try(:name) || s }
end
end
module UserAuthFinders
include Gitlab::Utils::StrongMemoize
PRIVATE_TOKEN_HEADER = 'HTTP_PRIVATE_TOKEN'.freeze
PRIVATE_TOKEN_PARAM = :private_token
# Check the Rails session for valid authentication details
def find_user_from_warden
current_request.env['warden']&.authenticate if verified_request?
end
def find_user_from_rss_token
return unless current_request.path.ends_with?('.atom') || current_request.format.atom?
token = current_request.params[:rss_token].presence
return unless token
User.find_by_rss_token(token) || raise(UnauthorizedError)
end
def find_user_from_access_token
return unless access_token
validate_access_token!
access_token.user || raise(UnauthorizedError)
end
def validate_access_token!(scopes: [])
return unless access_token
case AccessTokenValidationService.new(access_token, request: request).validate(scopes: scopes)
when AccessTokenValidationService::INSUFFICIENT_SCOPE
raise InsufficientScopeError.new(scopes)
when AccessTokenValidationService::EXPIRED
raise ExpiredError
when AccessTokenValidationService::REVOKED
raise RevokedError
end
end
private
def access_token
strong_memoize(:access_token) do
find_oauth_access_token || find_personal_access_token
end
end
def find_personal_access_token
token =
current_request.params[PRIVATE_TOKEN_PARAM].presence ||
current_request.env[PRIVATE_TOKEN_HEADER].presence
return unless token
# Expiration, revocation and scopes are verified in `validate_access_token!`
PersonalAccessToken.find_by(token: token) || raise(UnauthorizedError)
end
def find_oauth_access_token
token = Doorkeeper::OAuth::Token.from_request(current_request, *Doorkeeper.configuration.access_token_methods)
return unless token
# Expiration, revocation and scopes are verified in `validate_access_token!`
oauth_token = OauthAccessToken.by_token(token)
raise UnauthorizedError unless oauth_token
oauth_token.revoke_previous_refresh_token!
oauth_token
end
# Check if the request is GET/HEAD, or if CSRF token is valid.
def verified_request?
Gitlab::RequestForgeryProtection.verified?(current_request.env)
end
def ensure_action_dispatch_request(request)
return request if request.is_a?(ActionDispatch::Request)
ActionDispatch::Request.new(request.env)
end
def current_request
@current_request ||= ensure_action_dispatch_request(request)
end
end
end
end
require 'spec_helper'
describe Gitlab::Auth::RequestAuthenticator do
let(:env) do
{
'rack.input' => '',
'REQUEST_METHOD' => 'GET'
}
end
let(:request) { ActionDispatch::Request.new(env) }
subject { described_class.new(request) }
describe '#user' do
let!(:sessionless_user) { build(:user) }
let!(:session_user) { build(:user) }
it 'returns sessionless user first' do
allow_any_instance_of(described_class).to receive(:find_sessionless_user).and_return(sessionless_user)
allow_any_instance_of(described_class).to receive(:find_user_from_warden).and_return(session_user)
expect(subject.user).to eq sessionless_user
end
it 'returns session user if no sessionless user found' do
allow_any_instance_of(described_class).to receive(:find_user_from_warden).and_return(session_user)
expect(subject.user).to eq session_user
end
it 'returns nil if no user found' do
expect(subject.user).to be_blank
end
it 'bubbles up exceptions' do
allow_any_instance_of(described_class).to receive(:find_user_from_warden).and_raise(Gitlab::Auth::UnauthorizedError)
end
end
describe '#find_sessionless_user' do
let!(:access_token_user) { build(:user) }
let!(:rss_token_user) { build(:user) }
it 'returns access_token user first' do
allow_any_instance_of(described_class).to receive(:find_user_from_access_token).and_return(access_token_user)
allow_any_instance_of(described_class).to receive(:find_user_from_rss_token).and_return(rss_token_user)
expect(subject.find_sessionless_user).to eq access_token_user
end
it 'returns rss_token user if no access_token user found' do
allow_any_instance_of(described_class).to receive(:find_user_from_rss_token).and_return(rss_token_user)
expect(subject.find_sessionless_user).to eq rss_token_user
end
it 'returns nil if no user found' do
expect(subject.find_sessionless_user).to be_blank
end
it 'rescue Gitlab::Auth::AuthenticationError exceptions' do
allow_any_instance_of(described_class).to receive(:find_user_from_access_token).and_raise(Gitlab::Auth::UnauthorizedError)
expect(subject.find_sessionless_user).to be_blank
end
end
end
require 'spec_helper'
describe Gitlab::Auth::UserAuthFinders do
include described_class
let(:user) { create(:user) }
let(:env) do
{
'rack.input' => ''
}
end
let(:request) { Rack::Request.new(env)}
def set_param(key, value)
request.update_param(key, value)
end
describe '#find_user_from_warden' do
context 'with CSRF token' do
before do
allow(Gitlab::RequestForgeryProtection).to receive(:verified?).and_return(true)
end
context 'with invalid credentials' do
it 'returns nil' do
expect(find_user_from_warden).to be_nil
end
end
context 'with valid credentials' do
it 'returns the user' do
env['warden'] = double("warden", authenticate: user)
expect(find_user_from_warden).to eq user
end
end
end
context 'without CSRF token' do
it 'returns nil' do
allow(Gitlab::RequestForgeryProtection).to receive(:verified?).and_return(false)
env['warden'] = double("warden", authenticate: user)
expect(find_user_from_warden).to be_nil
end
end
end
describe '#find_user_from_rss_token' do
context 'when the request format is atom' do
before do
env['HTTP_ACCEPT'] = 'application/atom+xml'
end
it 'returns user if valid rss_token' do
set_param(:rss_token, user.rss_token)
expect(find_user_from_rss_token).to eq user
end
it 'returns nil if rss_token is blank' do
expect(find_user_from_rss_token).to be_nil
end
it 'returns exception if invalid rss_token' do
set_param(:rss_token, 'invalid_token')
expect { find_user_from_rss_token }.to raise_error(Gitlab::Auth::UnauthorizedError)
end
end
context 'when the request format is not atom' do
it 'returns nil' do
set_param(:rss_token, user.rss_token)
expect(find_user_from_rss_token).to be_nil
end
end
end
describe '#find_user_from_access_token' do
let(:personal_access_token) { create(:personal_access_token, user: user) }
it 'returns nil if no access_token present' do
expect(find_personal_access_token).to be_nil
end
context 'when validate_access_token! returns valid' do
it 'returns user' do
env[Gitlab::Auth::UserAuthFinders::PRIVATE_TOKEN_HEADER] = personal_access_token.token
expect(find_user_from_access_token).to eq user
end
it 'returns exception if token has no user' do
env[Gitlab::Auth::UserAuthFinders::PRIVATE_TOKEN_HEADER] = personal_access_token.token
allow_any_instance_of(PersonalAccessToken).to receive(:user).and_return(nil)
expect { find_user_from_access_token }.to raise_error(Gitlab::Auth::UnauthorizedError)
end
end
end
describe '#find_personal_access_token' do
let(:personal_access_token) { create(:personal_access_token, user: user) }
context 'passed as header' do
it 'returns token if valid personal_access_token' do
env[Gitlab::Auth::UserAuthFinders::PRIVATE_TOKEN_HEADER] = personal_access_token.token
expect(find_personal_access_token).to eq personal_access_token
end
end
context 'passed as param' do
it 'returns token if valid personal_access_token' do
set_param(Gitlab::Auth::UserAuthFinders::PRIVATE_TOKEN_PARAM, personal_access_token.token)
expect(find_personal_access_token).to eq personal_access_token
end
end
it 'returns nil if no personal_access_token' do
expect(find_personal_access_token).to be_nil
end
it 'returns exception if invalid personal_access_token' do
env[Gitlab::Auth::UserAuthFinders::PRIVATE_TOKEN_HEADER] = 'invalid_token'
expect { find_personal_access_token }.to raise_error(Gitlab::Auth::UnauthorizedError)
end
end
describe '#find_oauth_access_token' do
let(:application) { Doorkeeper::Application.create!(name: 'MyApp', redirect_uri: 'https://app.com', owner: user) }
let(:token) { Doorkeeper::AccessToken.create!(application_id: application.id, resource_owner_id: user.id, scopes: 'api') }
context 'passed as header' do
it 'returns token if valid oauth_access_token' do
env['HTTP_AUTHORIZATION'] = "Bearer #{token.token}"
expect(find_oauth_access_token.token).to eq token.token
end
end
context 'passed as param' do
it 'returns user if valid oauth_access_token' do
set_param(:access_token, token.token)
expect(find_oauth_access_token.token).to eq token.token
end
end
it 'returns nil if no oauth_access_token' do
expect(find_oauth_access_token).to be_nil
end
it 'returns exception if invalid oauth_access_token' do
env['HTTP_AUTHORIZATION'] = "Bearer invalid_token"
expect { find_oauth_access_token }.to raise_error(Gitlab::Auth::UnauthorizedError)
end
end
describe '#validate_access_token!' do
let(:personal_access_token) { create(:personal_access_token, user: user) }
it 'returns nil if no access_token present' do
expect(validate_access_token!).to be_nil
end
context 'token is not valid' do
before do
allow_any_instance_of(described_class).to receive(:access_token).and_return(personal_access_token)
end
it 'returns Gitlab::Auth::ExpiredError if token expired' do
personal_access_token.expires_at = 1.day.ago
expect { validate_access_token! }.to raise_error(Gitlab::Auth::ExpiredError)
end
it 'returns Gitlab::Auth::RevokedError if token revoked' do
personal_access_token.revoke!
expect { validate_access_token! }.to raise_error(Gitlab::Auth::RevokedError)
end
it 'returns Gitlab::Auth::InsufficientScopeError if invalid token scope' do
expect { validate_access_token!(scopes: [:sudo]) }.to raise_error(Gitlab::Auth::InsufficientScopeError)
end
end
end
end
...@@ -11,7 +11,6 @@ describe API::Helpers do ...@@ -11,7 +11,6 @@ describe API::Helpers do
let(:admin) { create(:admin) } let(:admin) { create(:admin) }
let(:key) { create(:key, user: user) } let(:key) { create(:key, user: user) }
let(:params) { {} }
let(:csrf_token) { SecureRandom.base64(ActionController::RequestForgeryProtection::AUTHENTICITY_TOKEN_LENGTH) } let(:csrf_token) { SecureRandom.base64(ActionController::RequestForgeryProtection::AUTHENTICITY_TOKEN_LENGTH) }
let(:env) do let(:env) do
{ {
...@@ -19,10 +18,13 @@ describe API::Helpers do ...@@ -19,10 +18,13 @@ describe API::Helpers do
'rack.session' => { 'rack.session' => {
_csrf_token: csrf_token _csrf_token: csrf_token
}, },
'REQUEST_METHOD' => 'GET' 'REQUEST_METHOD' => 'GET',
'CONTENT_TYPE' => 'text/plain;charset=utf-8'
} }
end end
let(:header) { } let(:header) { }
let(:request) { Grape::Request.new(env)}
let(:params) { request.params }
before do before do
allow_any_instance_of(self.class).to receive(:options).and_return({}) allow_any_instance_of(self.class).to receive(:options).and_return({})
...@@ -37,6 +39,10 @@ describe API::Helpers do ...@@ -37,6 +39,10 @@ describe API::Helpers do
raise Exception.new("#{status} - #{message}") raise Exception.new("#{status} - #{message}")
end end
def set_param(key, value)
request.update_param(key, value)
end
describe ".current_user" do describe ".current_user" do
subject { current_user } subject { current_user }
...@@ -132,13 +138,13 @@ describe API::Helpers do ...@@ -132,13 +138,13 @@ describe API::Helpers do
let(:personal_access_token) { create(:personal_access_token, user: user) } let(:personal_access_token) { create(:personal_access_token, user: user) }
it "returns a 401 response for an invalid token" do it "returns a 401 response for an invalid token" do
env[API::APIGuard::PRIVATE_TOKEN_HEADER] = 'invalid token' env[Gitlab::Auth::UserAuthFinders::PRIVATE_TOKEN_HEADER] = 'invalid token'
expect { current_user }.to raise_error /401/ expect { current_user }.to raise_error /401/
end end
it "returns a 403 response for a user without access" do it "returns a 403 response for a user without access" do
env[API::APIGuard::PRIVATE_TOKEN_HEADER] = personal_access_token.token env[Gitlab::Auth::UserAuthFinders::PRIVATE_TOKEN_HEADER] = personal_access_token.token
allow_any_instance_of(Gitlab::UserAccess).to receive(:allowed?).and_return(false) allow_any_instance_of(Gitlab::UserAccess).to receive(:allowed?).and_return(false)
expect { current_user }.to raise_error /403/ expect { current_user }.to raise_error /403/
...@@ -146,35 +152,35 @@ describe API::Helpers do ...@@ -146,35 +152,35 @@ describe API::Helpers do
it 'returns a 403 response for a user who is blocked' do it 'returns a 403 response for a user who is blocked' do
user.block! user.block!
env[API::APIGuard::PRIVATE_TOKEN_HEADER] = personal_access_token.token env[Gitlab::Auth::UserAuthFinders::PRIVATE_TOKEN_HEADER] = personal_access_token.token
expect { current_user }.to raise_error /403/ expect { current_user }.to raise_error /403/
end end
it "sets current_user" do it "sets current_user" do
env[API::APIGuard::PRIVATE_TOKEN_HEADER] = personal_access_token.token env[Gitlab::Auth::UserAuthFinders::PRIVATE_TOKEN_HEADER] = personal_access_token.token
expect(current_user).to eq(user) expect(current_user).to eq(user)
end end
it "does not allow tokens without the appropriate scope" do it "does not allow tokens without the appropriate scope" do
personal_access_token = create(:personal_access_token, user: user, scopes: ['read_user']) personal_access_token = create(:personal_access_token, user: user, scopes: ['read_user'])
env[API::APIGuard::PRIVATE_TOKEN_HEADER] = personal_access_token.token env[Gitlab::Auth::UserAuthFinders::PRIVATE_TOKEN_HEADER] = personal_access_token.token
expect { current_user }.to raise_error API::APIGuard::InsufficientScopeError expect { current_user }.to raise_error Gitlab::Auth::InsufficientScopeError
end end
it 'does not allow revoked tokens' do it 'does not allow revoked tokens' do
personal_access_token.revoke! personal_access_token.revoke!
env[API::APIGuard::PRIVATE_TOKEN_HEADER] = personal_access_token.token env[Gitlab::Auth::UserAuthFinders::PRIVATE_TOKEN_HEADER] = personal_access_token.token
expect { current_user }.to raise_error API::APIGuard::RevokedError expect { current_user }.to raise_error Gitlab::Auth::RevokedError
end end
it 'does not allow expired tokens' do it 'does not allow expired tokens' do
personal_access_token.update_attributes!(expires_at: 1.day.ago) personal_access_token.update_attributes!(expires_at: 1.day.ago)
env[API::APIGuard::PRIVATE_TOKEN_HEADER] = personal_access_token.token env[Gitlab::Auth::UserAuthFinders::PRIVATE_TOKEN_HEADER] = personal_access_token.token
expect { current_user }.to raise_error API::APIGuard::ExpiredError expect { current_user }.to raise_error Gitlab::Auth::ExpiredError
end end
end end
end end
...@@ -350,7 +356,7 @@ describe API::Helpers do ...@@ -350,7 +356,7 @@ describe API::Helpers do
context 'when using param' do context 'when using param' do
context 'when providing username' do context 'when providing username' do
before do before do
params[API::Helpers::SUDO_PARAM] = user.username set_param(API::Helpers::SUDO_PARAM, user.username)
end end
it_behaves_like 'successful sudo' it_behaves_like 'successful sudo'
...@@ -358,7 +364,7 @@ describe API::Helpers do ...@@ -358,7 +364,7 @@ describe API::Helpers do
context 'when providing user ID' do context 'when providing user ID' do
before do before do
params[API::Helpers::SUDO_PARAM] = user.id.to_s set_param(API::Helpers::SUDO_PARAM, user.id.to_s)
end end
it_behaves_like 'successful sudo' it_behaves_like 'successful sudo'
...@@ -368,7 +374,7 @@ describe API::Helpers do ...@@ -368,7 +374,7 @@ describe API::Helpers do
context 'when user does not exist' do context 'when user does not exist' do
before do before do
params[API::Helpers::SUDO_PARAM] = 'nonexistent' set_param(API::Helpers::SUDO_PARAM, 'nonexistent')
end end
it 'raises an error' do it 'raises an error' do
...@@ -382,11 +388,11 @@ describe API::Helpers do ...@@ -382,11 +388,11 @@ describe API::Helpers do
token.scopes = %w[api] token.scopes = %w[api]
token.save! token.save!
params[API::Helpers::SUDO_PARAM] = user.id.to_s set_param(API::Helpers::SUDO_PARAM, user.id.to_s)
end end
it 'raises an error' do it 'raises an error' do
expect { current_user }.to raise_error API::APIGuard::InsufficientScopeError expect { current_user }.to raise_error Gitlab::Auth::InsufficientScopeError
end end
end end
end end
...@@ -396,7 +402,7 @@ describe API::Helpers do ...@@ -396,7 +402,7 @@ describe API::Helpers do
token.user = user token.user = user
token.save! token.save!
params[API::Helpers::SUDO_PARAM] = user.id.to_s set_param(API::Helpers::SUDO_PARAM, user.id.to_s)
end end
it 'raises an error' do it 'raises an error' do
...@@ -420,7 +426,7 @@ describe API::Helpers do ...@@ -420,7 +426,7 @@ describe API::Helpers do
context 'passed as param' do context 'passed as param' do
before do before do
params[API::APIGuard::PRIVATE_TOKEN_PARAM] = token.token set_param(Gitlab::Auth::UserAuthFinders::PRIVATE_TOKEN_PARAM, token.token)
end end
it_behaves_like 'sudo' it_behaves_like 'sudo'
...@@ -428,7 +434,7 @@ describe API::Helpers do ...@@ -428,7 +434,7 @@ describe API::Helpers do
context 'passed as header' do context 'passed as header' do
before do before do
env[API::APIGuard::PRIVATE_TOKEN_HEADER] = token.token env[Gitlab::Auth::UserAuthFinders::PRIVATE_TOKEN_HEADER] = token.token
end end
it_behaves_like 'sudo' it_behaves_like 'sudo'
......
require 'spec_helper'
describe 'Rack Attack global throttles' do
let(:settings) { Gitlab::CurrentSettings.current_application_settings }
# Start with really high limits and override them with low limits to ensure
# the right settings are being exercised
let(:settings_to_set) do
{
throttle_unauthenticated_requests_per_period: 100,
throttle_unauthenticated_period_in_seconds: 1,
throttle_authenticated_api_requests_per_period: 100,
throttle_authenticated_api_period_in_seconds: 1,
throttle_authenticated_web_requests_per_period: 100,
throttle_authenticated_web_period_in_seconds: 1
}
end
let(:requests_per_period) { 1 }
let(:period_in_seconds) { 10000 }
let(:period) { period_in_seconds.seconds }
let(:url_that_does_not_require_authentication) { '/users/sign_in' }
let(:url_that_requires_authentication) { '/dashboard/snippets' }
let(:api_partial_url) { '/todos' }
around do |example|
# Instead of test environment's :null_store so the throttles can increment
Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new
# Make time-dependent tests deterministic
Timecop.freeze { example.run }
Rack::Attack.cache.store = Rails.cache
end
# Requires let variables:
# * throttle_setting_prefix (e.g. "throttle_authenticated_api" or "throttle_authenticated_web")
# * get_args
# * other_user_get_args
shared_examples_for 'rate-limited token-authenticated requests' do
before do
# Set low limits
settings_to_set[:"#{throttle_setting_prefix}_requests_per_period"] = requests_per_period
settings_to_set[:"#{throttle_setting_prefix}_period_in_seconds"] = period_in_seconds
end
context 'when the throttle is enabled' do
before do
settings_to_set[:"#{throttle_setting_prefix}_enabled"] = true
stub_application_setting(settings_to_set)
end
it 'rejects requests over the rate limit' do
# At first, allow requests under the rate limit.
requests_per_period.times do
get(*get_args)
expect(response).to have_http_status 200
end
# the last straw
expect_rejection { get(*get_args) }
end
it 'allows requests after throttling and then waiting for the next period' do
requests_per_period.times do
get(*get_args)
expect(response).to have_http_status 200
end
expect_rejection { get(*get_args) }
Timecop.travel(period.from_now) do
requests_per_period.times do
get(*get_args)
expect(response).to have_http_status 200
end
expect_rejection { get(*get_args) }
end
end
it 'counts requests from different users separately, even from the same IP' do
requests_per_period.times do
get(*get_args)
expect(response).to have_http_status 200
end
# would be over the limit if this wasn't a different user
get(*other_user_get_args)
expect(response).to have_http_status 200
end
it 'counts all requests from the same user, even via different IPs' do
requests_per_period.times do
get(*get_args)
expect(response).to have_http_status 200
end
expect_any_instance_of(Rack::Attack::Request).to receive(:ip).and_return('1.2.3.4')
expect_rejection { get(*get_args) }
end
end
context 'when the throttle is disabled' do
before do
settings_to_set[:"#{throttle_setting_prefix}_enabled"] = false
stub_application_setting(settings_to_set)
end
it 'allows requests over the rate limit' do
(1 + requests_per_period).times do
get(*get_args)
expect(response).to have_http_status 200
end
end
end
end
describe 'unauthenticated requests' do
before do
# Set low limits
settings_to_set[:throttle_unauthenticated_requests_per_period] = requests_per_period
settings_to_set[:throttle_unauthenticated_period_in_seconds] = period_in_seconds
end
context 'when the throttle is enabled' do
before do
settings_to_set[:throttle_unauthenticated_enabled] = true
stub_application_setting(settings_to_set)
end
it 'rejects requests over the rate limit' do
# At first, allow requests under the rate limit.
requests_per_period.times do
get url_that_does_not_require_authentication
expect(response).to have_http_status 200
end
# the last straw
expect_rejection { get url_that_does_not_require_authentication }
end
it 'allows requests after throttling and then waiting for the next period' do
requests_per_period.times do
get url_that_does_not_require_authentication
expect(response).to have_http_status 200
end
expect_rejection { get url_that_does_not_require_authentication }
Timecop.travel(period.from_now) do
requests_per_period.times do
get url_that_does_not_require_authentication
expect(response).to have_http_status 200
end
expect_rejection { get url_that_does_not_require_authentication }
end
end
it 'counts requests from different IPs separately' do
requests_per_period.times do
get url_that_does_not_require_authentication
expect(response).to have_http_status 200
end
expect_any_instance_of(Rack::Attack::Request).to receive(:ip).and_return('1.2.3.4')
# would be over limit for the same IP
get url_that_does_not_require_authentication
expect(response).to have_http_status 200
end
end
context 'when the throttle is disabled' do
before do
settings_to_set[:throttle_unauthenticated_enabled] = false
stub_application_setting(settings_to_set)
end
it 'allows requests over the rate limit' do
(1 + requests_per_period).times do
get url_that_does_not_require_authentication
expect(response).to have_http_status 200
end
end
end
end
describe 'API requests authenticated with personal access token', :api do
let(:user) { create(:user) }
let(:token) { create(:personal_access_token, user: user) }
let(:other_user) { create(:user) }
let(:other_user_token) { create(:personal_access_token, user: other_user) }
let(:throttle_setting_prefix) { 'throttle_authenticated_api' }
context 'with the token in the query string' do
let(:get_args) { [api(api_partial_url, personal_access_token: token)] }
let(:other_user_get_args) { [api(api_partial_url, personal_access_token: other_user_token)] }
it_behaves_like 'rate-limited token-authenticated requests'
end
context 'with the token in the headers' do
let(:get_args) { api_get_args_with_token_headers(api_partial_url, personal_access_token_headers(token)) }
let(:other_user_get_args) { api_get_args_with_token_headers(api_partial_url, personal_access_token_headers(other_user_token)) }
it_behaves_like 'rate-limited token-authenticated requests'
end
end
describe 'API requests authenticated with OAuth token', :api do
let(:user) { create(:user) }
let(:application) { Doorkeeper::Application.create!(name: "MyApp", redirect_uri: "https://app.com", owner: user) }
let(:token) { Doorkeeper::AccessToken.create!(application_id: application.id, resource_owner_id: user.id, scopes: "api") }
let(:other_user) { create(:user) }
let(:other_user_application) { Doorkeeper::Application.create!(name: "MyApp", redirect_uri: "https://app.com", owner: other_user) }
let(:other_user_token) { Doorkeeper::AccessToken.create!(application_id: application.id, resource_owner_id: other_user.id, scopes: "api") }
let(:throttle_setting_prefix) { 'throttle_authenticated_api' }
context 'with the token in the query string' do
let(:get_args) { [api(api_partial_url, oauth_access_token: token)] }
let(:other_user_get_args) { [api(api_partial_url, oauth_access_token: other_user_token)] }
it_behaves_like 'rate-limited token-authenticated requests'
end
context 'with the token in the headers' do
let(:get_args) { api_get_args_with_token_headers(api_partial_url, oauth_token_headers(token)) }
let(:other_user_get_args) { api_get_args_with_token_headers(api_partial_url, oauth_token_headers(other_user_token)) }
it_behaves_like 'rate-limited token-authenticated requests'
end
end
describe '"web" (non-API) requests authenticated with RSS token' do
let(:user) { create(:user) }
let(:other_user) { create(:user) }
let(:throttle_setting_prefix) { 'throttle_authenticated_web' }
context 'with the token in the query string' do
let(:get_args) { [rss_url(user), nil] }
let(:other_user_get_args) { [rss_url(other_user), nil] }
it_behaves_like 'rate-limited token-authenticated requests'
end
end
describe 'web requests authenticated with regular login' do
let(:user) { create(:user) }
before do
login_as(user)
# Set low limits
settings_to_set[:throttle_authenticated_web_requests_per_period] = requests_per_period
settings_to_set[:throttle_authenticated_web_period_in_seconds] = period_in_seconds
end
context 'when the throttle is enabled' do
before do
settings_to_set[:throttle_authenticated_web_enabled] = true
stub_application_setting(settings_to_set)
end
it 'rejects requests over the rate limit' do
# At first, allow requests under the rate limit.
requests_per_period.times do
get url_that_requires_authentication
expect(response).to have_http_status 200
end
# the last straw
expect_rejection { get url_that_requires_authentication }
end
it 'allows requests after throttling and then waiting for the next period' do
requests_per_period.times do
get url_that_requires_authentication
expect(response).to have_http_status 200
end
expect_rejection { get url_that_requires_authentication }
Timecop.travel(period.from_now) do
requests_per_period.times do
get url_that_requires_authentication
expect(response).to have_http_status 200
end
expect_rejection { get url_that_requires_authentication }
end
end
it 'counts requests from different users separately, even from the same IP' do
requests_per_period.times do
get url_that_requires_authentication
expect(response).to have_http_status 200
end
# would be over the limit if this wasn't a different user
login_as(create(:user))
get url_that_requires_authentication
expect(response).to have_http_status 200
end
it 'counts all requests from the same user, even via different IPs' do
requests_per_period.times do
get url_that_requires_authentication
expect(response).to have_http_status 200
end
expect_any_instance_of(Rack::Attack::Request).to receive(:ip).and_return('1.2.3.4')
expect_rejection { get url_that_requires_authentication }
end
end
context 'when the throttle is disabled' do
before do
settings_to_set[:throttle_authenticated_web_enabled] = false
stub_application_setting(settings_to_set)
end
it 'allows requests over the rate limit' do
(1 + requests_per_period).times do
get url_that_requires_authentication
expect(response).to have_http_status 200
end
end
end
end
def api_get_args_with_token_headers(partial_url, token_headers)
["/api/#{API::API.version}#{partial_url}", nil, token_headers]
end
def rss_url(user)
"/dashboard/projects.atom?rss_token=#{user.rss_token}"
end
def private_token_headers(user)
{ 'HTTP_PRIVATE_TOKEN' => user.private_token }
end
def personal_access_token_headers(personal_access_token)
{ 'HTTP_PRIVATE_TOKEN' => personal_access_token.token }
end
def oauth_token_headers(oauth_access_token)
{ 'AUTHORIZATION' => "Bearer #{oauth_access_token.token}" }
end
def expect_rejection(&block)
yield
expect(response).to have_http_status(429)
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment