gitlab-org--gitlab-foss/lib/gitlab/web_hooks/recursion_detection.rb

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

95 lines
3.1 KiB
Ruby
Raw Normal View History

# 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