summaryrefslogtreecommitdiff
path: root/lib/gitlab/rack_attack.rb
blob: 3f4c0fa45aa96aaf8d482dbc412716644f1723fa (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
# frozen_string_literal: true

# When adding new user-configurable throttles, remember to update the documentation
# in doc/user/admin_area/settings/user_and_ip_rate_limits.md
#
# Integration specs for throttling can be found in:
# spec/requests/rack_attack_global_spec.rb
module Gitlab
  module RackAttack
    def self.configure(rack_attack)
      # This adds some methods used by our throttles to the `Rack::Request`
      rack_attack::Request.include(Gitlab::RackAttack::Request)

      # This is Rack::Attack::DEFAULT_THROTTLED_RESPONSE, modified to allow a custom response
      rack_attack.throttled_response = lambda do |env|
        throttled_headers = Gitlab::RackAttack.throttled_response_headers(
          env['rack.attack.matched'], env['rack.attack.match_data']
        )
        [429, { 'Content-Type' => 'text/plain' }.merge(throttled_headers), [Gitlab::Throttle.rate_limiting_response_text]]
      end

      rack_attack.cache.store = Gitlab::RackAttack::InstrumentedCacheStore.new

      # Configure the throttles
      configure_throttles(rack_attack)

      configure_user_allowlist
    end

    # Rate Limit HTTP headers are not standardized anywhere. This is the latest
    # draft submitted to IETF:
    # https://github.com/ietf-wg-httpapi/ratelimit-headers/blob/main/draft-ietf-httpapi-ratelimit-headers.md
    #
    # This method implement the most viable parts of the headers. Those headers
    # will be sent back to the client when it gets throttled.
    #
    # - RateLimit-Limit: indicates the request quota associated to the client
    # in 60 seconds. The time window for the quota here is supposed to be
    # mirrored to throttle_*_period_in_seconds application settings.  However,
    # our HAProxy as well as some ecosystem libraries are using a fixed
    # 60-second window. Therefore, the returned limit is approximately rounded
    # up to fit into that window.
    #
    # - RateLimit-Observed: indicates the current request amount associated to
    # the client within the time window.
    #
    # - RateLimit-Remaining: indicates the remaining quota within the time
    # window. It is the result of RateLimit-Limit - RateLimit-Remaining
    #
    # - Retry-After: the remaining duration in seconds until the quota is
    # reset. This is a standardized HTTP header:
    # https://tools.ietf.org/html/rfc7231#page-69
    #
    # - RateLimit-Reset: the point of time that the request quota is reset, in Unix time
    #
    # - RateLimit-ResetTime: the point of time that the request quota is reset, in HTTP date format
    def self.throttled_response_headers(matched, match_data)
      # Match data example:
      # {:discriminator=>"127.0.0.1", :count=>12, :period=>60 seconds, :limit=>1, :epoch_time=>1609833930}
      # Source: https://github.com/rack/rack-attack/blob/v6.3.0/lib/rack/attack/throttle.rb#L33
      period = match_data[:period]
      limit = match_data[:limit]
      rounded_limit = (limit.to_f * 1.minute / match_data[:period]).ceil
      observed = match_data[:count]
      now = match_data[:epoch_time]
      retry_after = period - (now % period)
      reset_time = Time.at(now + retry_after) # rubocop:disable Rails/TimeZone
      {
        'RateLimit-Name' => matched.to_s,
        'RateLimit-Limit' => rounded_limit.to_s,
        'RateLimit-Observed' => observed.to_s,
        'RateLimit-Remaining' => (limit > observed ? limit - observed : 0).to_s,
        'RateLimit-Reset' => reset_time.to_i.to_s,
        'RateLimit-ResetTime' => reset_time.httpdate,
        'Retry-After' => retry_after.to_s
      }
    end

    def self.configure_user_allowlist
      @user_allowlist = nil
      user_allowlist
    end

    def self.configure_throttles(rack_attack)
      # Each of these settings follows the same pattern of specifying separate
      # authenticated and unauthenticated rates via settings
      Gitlab::Throttle::REGULAR_THROTTLES.each do |throttle|
        unauthenticated_options = Gitlab::Throttle.options(throttle, authenticated: false)
        throttle_or_track(rack_attack, "throttle_unauthenticated_#{throttle}", unauthenticated_options) do |req|
          if req.throttle?(throttle, authenticated: false)
            req.ip
          end
        end

        authenticated_options = Gitlab::Throttle.options(throttle, authenticated: true)
        throttle_or_track(rack_attack, "throttle_authenticated_#{throttle}", authenticated_options) do |req|
          if req.throttle?(throttle, authenticated: true)
            req.throttled_user_id([:api])
          end
        end
      end

      throttle_or_track(rack_attack, 'throttle_unauthenticated_web', Gitlab::Throttle.unauthenticated_web_options) do |req|
        if req.throttle_unauthenticated_web?
          req.ip
        end
      end

      # Product analytics feature is in experimental stage.
      # At this point we want to limit amount of events registered
      # per application (aid stands for application id).
      throttle_or_track(rack_attack, 'throttle_product_analytics_collector', limit: 100, period: 60) do |req|
        if req.product_analytics_collector_request?
          req.params['aid']
        end
      end

      throttle_or_track(rack_attack, 'throttle_authenticated_web', Gitlab::Throttle.authenticated_web_options) do |req|
        if req.throttle_authenticated_web?
          req.throttled_user_id([:api, :rss, :ics])
        end
      end

      throttle_or_track(rack_attack, 'throttle_unauthenticated_protected_paths', Gitlab::Throttle.protected_paths_options) do |req|
        if req.throttle_unauthenticated_protected_paths?
          req.ip
        end
      end

      throttle_or_track(rack_attack, 'throttle_authenticated_protected_paths_api', Gitlab::Throttle.protected_paths_options) do |req|
        if req.throttle_authenticated_protected_paths_api?
          req.throttled_user_id([:api])
        end
      end

      throttle_or_track(rack_attack, 'throttle_authenticated_protected_paths_web', Gitlab::Throttle.protected_paths_options) do |req|
        if req.throttle_authenticated_protected_paths_web?
          req.throttled_user_id([:api, :rss, :ics])
        end
      end

      throttle_or_track(rack_attack, 'throttle_authenticated_git_lfs', Gitlab::Throttle.throttle_authenticated_git_lfs_options) do |req|
        if req.throttle_authenticated_git_lfs?
          req.throttled_user_id([:api])
        end
      end

      rack_attack.safelist('throttle_bypass_header') do |req|
        Gitlab::Throttle.bypass_header.present? &&
          req.get_header(Gitlab::Throttle.bypass_header) == '1'
      end
    end

    def self.throttle_or_track(rack_attack, throttle_name, *args, &block)
      if track?(throttle_name)
        rack_attack.track(throttle_name, *args, &block)
      else
        rack_attack.throttle(throttle_name, *args, &block)
      end
    end

    def self.track?(name)
      dry_run_config = ENV['GITLAB_THROTTLE_DRY_RUN'].to_s.strip

      return false if dry_run_config.empty?
      return true if dry_run_config == '*'

      dry_run_throttles = dry_run_config.split(',').map(&:strip)

      # `throttle_unauthenticated` was split into API and web, so to maintain backwards-compatibility
      # this throttle name now controls both rate limits.
      if dry_run_throttles.include?('throttle_unauthenticated')
        dry_run_throttles += %w[throttle_unauthenticated_api throttle_unauthenticated_web]
      end

      dry_run_throttles.include?(name)
    end

    def self.user_allowlist
      @user_allowlist ||= begin
        list = UserAllowlist.new(ENV['GITLAB_THROTTLE_USER_ALLOWLIST'])
        Gitlab::AuthLogger.info(gitlab_throttle_user_allowlist: list.to_a)
        list
      end
    end
  end
end
::Gitlab::RackAttack.prepend_mod_with('Gitlab::RackAttack')