summaryrefslogtreecommitdiff
path: root/lib/gitlab/web_hooks/recursion_detection.rb
diff options
context:
space:
mode:
Diffstat (limited to 'lib/gitlab/web_hooks/recursion_detection.rb')
-rw-r--r--lib/gitlab/web_hooks/recursion_detection.rb94
1 files changed, 94 insertions, 0 deletions
diff --git a/lib/gitlab/web_hooks/recursion_detection.rb b/lib/gitlab/web_hooks/recursion_detection.rb
new file mode 100644
index 00000000000..1b5350d4a4e
--- /dev/null
+++ b/lib/gitlab/web_hooks/recursion_detection.rb
@@ -0,0 +1,94 @@
+# 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