95 lines
		
	
	
		
			3.1 KiB
		
	
	
	
		
			Ruby
		
	
	
	
			
		
		
	
	
			95 lines
		
	
	
		
			3.1 KiB
		
	
	
	
		
			Ruby
		
	
	
	
# frozen_string_literal: true
 | 
						|
 | 
						|
# This module detects and blocks recursive webhook requests.
 | 
						|
#
 | 
						|
# Recursion can happen when a webhook has been configured to make a call
 | 
						|
# to its own GitLab instance (i.e., its API), and during the execution of
 | 
						|
# the call the webhook is triggered again to create an infinite loop of
 | 
						|
# being triggered.
 | 
						|
#
 | 
						|
# Additionally the module blocks a webhook once the number of requests to
 | 
						|
# the instance made by a series of webhooks triggering other webhooks reaches
 | 
						|
# a limit.
 | 
						|
#
 | 
						|
# Blocking recursive webhooks allows GitLab to continue to support workflows
 | 
						|
# that use webhooks to call the API non-recursively, or do not go on to
 | 
						|
# trigger an unreasonable number of other webhooks.
 | 
						|
module Gitlab
 | 
						|
  module WebHooks
 | 
						|
    module RecursionDetection
 | 
						|
      COUNT_LIMIT = 100
 | 
						|
      TOUCH_CACHE_TTL = 30.minutes
 | 
						|
 | 
						|
      class << self
 | 
						|
        def set_from_headers(headers)
 | 
						|
          uuid = headers[UUID::HEADER]
 | 
						|
 | 
						|
          return unless uuid
 | 
						|
 | 
						|
          set_request_uuid(uuid)
 | 
						|
        end
 | 
						|
 | 
						|
        def set_request_uuid(uuid)
 | 
						|
          UUID.instance.request_uuid = uuid
 | 
						|
        end
 | 
						|
 | 
						|
        # Before a webhook is executed, `.register!` should be called.
 | 
						|
        # Adds the webhook ID to a cache (see `#cache_key_for_hook` for
 | 
						|
        # details of the cache).
 | 
						|
        def register!(hook)
 | 
						|
          cache_key = cache_key_for_hook(hook)
 | 
						|
 | 
						|
          ::Gitlab::Redis::SharedState.with do |redis|
 | 
						|
            redis.multi do
 | 
						|
              redis.sadd(cache_key, hook.id)
 | 
						|
              redis.expire(cache_key, TOUCH_CACHE_TTL)
 | 
						|
            end
 | 
						|
          end
 | 
						|
        end
 | 
						|
 | 
						|
        # Returns true if the webhook ID is present in the cache, or if the
 | 
						|
        # number of IDs in the cache exceeds the limit (see
 | 
						|
        # `#cache_key_for_hook` for details of the cache).
 | 
						|
        def block?(hook)
 | 
						|
          # If a request UUID has not been set then we know the request was not
 | 
						|
          # made by a webhook, and no recursion is possible.
 | 
						|
          return false unless UUID.instance.request_uuid
 | 
						|
 | 
						|
          cache_key = cache_key_for_hook(hook)
 | 
						|
 | 
						|
          ::Gitlab::Redis::SharedState.with do |redis|
 | 
						|
            redis.sismember(cache_key, hook.id) ||
 | 
						|
              redis.scard(cache_key) >= COUNT_LIMIT
 | 
						|
          end
 | 
						|
        end
 | 
						|
 | 
						|
        def header(hook)
 | 
						|
          UUID.instance.header(hook)
 | 
						|
        end
 | 
						|
 | 
						|
        def to_log(hook)
 | 
						|
          {
 | 
						|
            uuid: UUID.instance.uuid_for_hook(hook),
 | 
						|
            ids: ::Gitlab::Redis::SharedState.with { |redis| redis.smembers(cache_key_for_hook(hook)).map(&:to_i) }
 | 
						|
          }
 | 
						|
        end
 | 
						|
 | 
						|
        private
 | 
						|
 | 
						|
        # Returns a cache key scoped to a UUID.
 | 
						|
        #
 | 
						|
        # The particular UUID will be either:
 | 
						|
        #
 | 
						|
        #   - A UUID that was recycled from the request headers if the request was made by a webhook.
 | 
						|
        #   - a new UUID initialized for the webhook.
 | 
						|
        #
 | 
						|
        # This means that cycles of webhooks that are triggered from other webhooks
 | 
						|
        # will share the same cache, and other webhooks will use a new cache.
 | 
						|
        def cache_key_for_hook(hook)
 | 
						|
          [:webhook_recursion_detection, UUID.instance.uuid_for_hook(hook)].join(':')
 | 
						|
        end
 | 
						|
      end
 | 
						|
    end
 | 
						|
  end
 | 
						|
end
 |