bullet_train-outgoing_webhooks 1.0.4 → 1.0.5

Sign up to get free protection for your applications and to get access to all the features.
Files changed (28) hide show
  1. checksums.yaml +4 -4
  2. data/MIT-LICENSE +1 -1
  3. data/app/controllers/account/webhooks/outgoing/endpoints_controller.rb +4 -3
  4. data/app/controllers/api/v1/webhooks/outgoing/endpoints_endpoint.rb +7 -7
  5. data/app/jobs/webhooks/outgoing/generate_job.rb +7 -0
  6. data/app/models/concerns/webhooks/outgoing/delivery_attempt_support.rb +64 -0
  7. data/app/models/concerns/webhooks/outgoing/delivery_support.rb +66 -0
  8. data/app/models/concerns/webhooks/outgoing/endpoint_support.rb +37 -0
  9. data/app/models/concerns/webhooks/outgoing/event_support.rb +45 -0
  10. data/app/models/concerns/webhooks/outgoing/issuing_model.rb +35 -17
  11. data/app/models/concerns/webhooks/outgoing/team_support.rb +16 -0
  12. data/app/models/concerns/webhooks/outgoing/uri_filtering.rb +149 -0
  13. data/app/models/webhooks/outgoing/delivery.rb +2 -61
  14. data/app/models/webhooks/outgoing/delivery_attempt.rb +2 -47
  15. data/app/models/webhooks/outgoing/endpoint.rb +2 -14
  16. data/app/models/webhooks/outgoing/event.rb +2 -40
  17. data/app/views/account/webhooks/outgoing/deliveries/_menu_item.html.erb +2 -2
  18. data/app/views/account/webhooks/outgoing/delivery_attempts/_menu_item.html.erb +2 -2
  19. data/app/views/account/webhooks/outgoing/endpoints/_breadcrumbs.html.erb +1 -1
  20. data/app/views/account/webhooks/outgoing/endpoints/_form.html.erb +2 -2
  21. data/app/views/account/webhooks/outgoing/endpoints/_index.html.erb +3 -3
  22. data/app/views/account/webhooks/outgoing/endpoints/_menu_item.html.erb +2 -2
  23. data/app/views/account/webhooks/outgoing/endpoints/show.html.erb +1 -1
  24. data/config/routes.rb +1 -1
  25. data/lib/bullet_train/outgoing_webhooks/engine.rb +26 -1
  26. data/lib/bullet_train/outgoing_webhooks/version.rb +1 -1
  27. data/lib/bullet_train/outgoing_webhooks.rb +26 -1
  28. metadata +39 -5
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e4186cf970999d91997f2410efeba593f422788cec8452978544796176d7ea47
4
- data.tar.gz: 9700fff07690d45d249e966e29cd233f894b0f77fbffbc4cacf47a637ebb7be6
3
+ metadata.gz: a4d3884adf8d199b92ccf09ca749e8237e86a421224de5b8b81cf5ab0d9f4329
4
+ data.tar.gz: cec7b468644c484f8bcb0f4064b74764ab3e7b8873cb72ba5c8944009b95a576
5
5
  SHA512:
6
- metadata.gz: 7b971859d534310367961541028daef0b95286d02d1bbe202ac954228ed092dacf6180277193d8b4822547838c20d3d60a7efb0eab1e56160eecf48fe7ac6606
7
- data.tar.gz: 268648e2e285ca1398f4b526529c6a7de6a8e8efd461e73fc68003dd579fb75a335a77d3b461efd9d46b906aebc93bdd0f3fdbd6f8191dd60774deada233c491
6
+ metadata.gz: 98d7265e65165a0c2b55a0b2880063f00c72292e0a461e4bde667d54f077860f1b4861873f5131bd203e45b85ab03868822970071e626c34e2ed640059e5cc11
7
+ data.tar.gz: f36d57ccd33ba9268a106ef22c1457560f9b171ff0533b4f8102301e5962691f827ffa437c7f13d3fd7ab847b1b626a9c6374cca45efbbcab228caf7c265d2a9
data/MIT-LICENSE CHANGED
@@ -1,4 +1,4 @@
1
- Copyright 2022 Andrew Culver
1
+ Copyright 2022 Bullet Train, Inc.
2
2
 
3
3
  Permission is hereby granted, free of charge, to any person obtaining
4
4
  a copy of this software and associated documentation files (the
@@ -1,5 +1,6 @@
1
1
  class Account::Webhooks::Outgoing::EndpointsController < Account::ApplicationController
2
- account_load_and_authorize_resource :endpoint, through: :team, through_association: :webhooks_outgoing_endpoints
2
+ account_load_and_authorize_resource :endpoint, through: BulletTrain::OutgoingWebhooks.parent_association, through_association: :webhooks_outgoing_endpoints
3
+ before_action { @parent = instance_variable_get("@#{BulletTrain::OutgoingWebhooks.parent_association}") }
3
4
 
4
5
  # GET /account/teams/:team_id/webhooks/outgoing/endpoints
5
6
  # GET /account/teams/:team_id/webhooks/outgoing/endpoints.json
@@ -26,7 +27,7 @@ class Account::Webhooks::Outgoing::EndpointsController < Account::ApplicationCon
26
27
  def create
27
28
  respond_to do |format|
28
29
  if @endpoint.save
29
- format.html { redirect_to [:account, @team, :webhooks_outgoing_endpoints], notice: I18n.t("webhooks/outgoing/endpoints.notifications.created") }
30
+ format.html { redirect_to [:account, @parent, :webhooks_outgoing_endpoints], notice: I18n.t("webhooks/outgoing/endpoints.notifications.created") }
30
31
  format.json { render :show, status: :created, location: [:account, @endpoint] }
31
32
  else
32
33
  format.html { render :new, status: :unprocessable_entity }
@@ -54,7 +55,7 @@ class Account::Webhooks::Outgoing::EndpointsController < Account::ApplicationCon
54
55
  def destroy
55
56
  @endpoint.destroy
56
57
  respond_to do |format|
57
- format.html { redirect_to [:account, @team, :webhooks_outgoing_endpoints], notice: I18n.t("webhooks/outgoing/endpoints.notifications.destroyed") }
58
+ format.html { redirect_to [:account, @parent, :webhooks_outgoing_endpoints], notice: I18n.t("webhooks/outgoing/endpoints.notifications.destroyed") }
58
59
  format.json { head :no_content }
59
60
  end
60
61
  end
@@ -1,7 +1,7 @@
1
1
  class Api::V1::Webhooks::Outgoing::EndpointsEndpoint < Api::V1::Root
2
2
  helpers do
3
- params :team_id do
4
- requires :team_id, type: Integer, allow_blank: false, desc: "Team ID"
3
+ params BulletTrain::OutgoingWebhooks.parent_association_id do
4
+ requires BulletTrain::OutgoingWebhooks.parent_association_id, type: Integer, allow_blank: false, desc: "#{BulletTrain::OutgoingWebhooks.parent_class} ID"
5
5
  end
6
6
 
7
7
  params :id do
@@ -19,7 +19,7 @@ class Api::V1::Webhooks::Outgoing::EndpointsEndpoint < Api::V1::Root
19
19
  end
20
20
  end
21
21
 
22
- resource "teams", desc: Api.title(:collection_actions) do
22
+ resource BulletTrain::OutgoingWebhooks.parent_resource, desc: Api.title(:collection_actions) do
23
23
  after_validation do
24
24
  load_and_authorize_api_resource Webhooks::Outgoing::Endpoint
25
25
  end
@@ -30,11 +30,11 @@ class Api::V1::Webhooks::Outgoing::EndpointsEndpoint < Api::V1::Root
30
30
 
31
31
  desc Api.title(:index), &Api.index_desc
32
32
  params do
33
- use :team_id
33
+ use BulletTrain::OutgoingWebhooks.parent_association_id
34
34
  end
35
35
  oauth2
36
36
  paginate per_page: 100
37
- get "/:team_id/webhooks/outgoing/endpoints" do
37
+ get "/:#{BulletTrain::OutgoingWebhooks.parent_association_id}/webhooks/outgoing/endpoints" do
38
38
  @paginated_endpoints = paginate @endpoints
39
39
  render @paginated_endpoints, serializer: Api.serializer
40
40
  end
@@ -45,12 +45,12 @@ class Api::V1::Webhooks::Outgoing::EndpointsEndpoint < Api::V1::Root
45
45
 
46
46
  desc Api.title(:create), &Api.create_desc
47
47
  params do
48
- use :team_id
48
+ use BulletTrain::OutgoingWebhooks.parent_association_id
49
49
  use :endpoint
50
50
  end
51
51
  route_setting :api_resource_options, permission: :create
52
52
  oauth2 "write"
53
- post "/:team_id/webhooks/outgoing/endpoints" do
53
+ post "/:#{BulletTrain::OutgoingWebhooks.parent_association_id}/webhooks/outgoing/endpoints" do
54
54
  if @endpoint.save
55
55
  render @endpoint, serializer: Api.serializer
56
56
  else
@@ -0,0 +1,7 @@
1
+ class Webhooks::Outgoing::GenerateJob < ApplicationJob
2
+ queue_as :default
3
+
4
+ def perform(obj, action)
5
+ obj.generate_webhook_perform(action)
6
+ end
7
+ end
@@ -0,0 +1,64 @@
1
+ module Webhooks::Outgoing::DeliveryAttemptSupport
2
+ extend ActiveSupport::Concern
3
+ include Webhooks::Outgoing::UriFiltering
4
+
5
+ SUCCESS_RESPONSE_CODES = [200, 201, 202, 203, 204, 205, 206, 207, 226].freeze
6
+
7
+ included do
8
+ belongs_to :delivery
9
+ has_one :team, through: :delivery unless BulletTrain::OutgoingWebhooks.parent_class_specified?
10
+ scope :successful, -> { where(response_code: SUCCESS_RESPONSE_CODES) }
11
+
12
+ before_create do
13
+ self.attempt_number = delivery.attempt_count + 1
14
+ end
15
+
16
+ validates :response_code, presence: true
17
+ end
18
+
19
+ def still_attempting?
20
+ error_message.nil? && response_code.nil?
21
+ end
22
+
23
+ def successful?
24
+ SUCCESS_RESPONSE_CODES.include?(response_code)
25
+ end
26
+
27
+ def failed?
28
+ !(successful? || still_attempting?)
29
+ end
30
+
31
+ def attempt
32
+ uri = URI.parse(delivery.endpoint_url)
33
+
34
+ unless allowed_uri?(uri)
35
+ self.response_code = 0
36
+ self.error_message = "URI is not allowed: " + uri
37
+ return false
38
+ end
39
+
40
+ http = Net::HTTP.new(resolve_ip_from_authoritative(uri.hostname.downcase), uri.port)
41
+ http.use_ssl = true if uri.scheme == "https"
42
+ request = Net::HTTP::Post.new(uri.path)
43
+ request.add_field("Host", uri.host)
44
+ request.add_field("Content-Type", "application/json")
45
+ request.body = delivery.event.payload.to_json
46
+
47
+ begin
48
+ response = http.request(request)
49
+ self.response_message = response.message
50
+ self.response_code = response.code
51
+ self.response_body = response.body
52
+ rescue => exception
53
+ self.response_code = 0
54
+ self.error_message = exception.message
55
+ end
56
+
57
+ save
58
+ successful?
59
+ end
60
+
61
+ def label_string
62
+ "#{attempt_number.ordinalize} Attempt"
63
+ end
64
+ end
@@ -0,0 +1,66 @@
1
+ module Webhooks::Outgoing::DeliverySupport
2
+ extend ActiveSupport::Concern
3
+
4
+ included do
5
+ belongs_to :endpoint, class_name: "Webhooks::Outgoing::Endpoint"
6
+ belongs_to :event, class_name: "Webhooks::Outgoing::Event"
7
+
8
+ has_one :team, through: :endpoint unless BulletTrain::OutgoingWebhooks.parent_class_specified?
9
+ has_many :delivery_attempts, class_name: "Webhooks::Outgoing::DeliveryAttempt", dependent: :destroy, foreign_key: :delivery_id
10
+ end
11
+
12
+ ATTEMPT_SCHEDULE = {
13
+ 1 => 15.seconds,
14
+ 2 => 1.minute,
15
+ 3 => 5.minutes,
16
+ 4 => 15.minutes,
17
+ 5 => 1.hour,
18
+ }
19
+
20
+ def label_string
21
+ event.short_uuid
22
+ end
23
+
24
+ def next_reattempt_delay
25
+ ATTEMPT_SCHEDULE[attempt_count]
26
+ end
27
+
28
+ def deliver_async
29
+ if still_attempting?
30
+ Webhooks::Outgoing::DeliveryJob.set(wait: next_reattempt_delay).perform_later(self)
31
+ end
32
+ end
33
+
34
+ def deliver
35
+ if delivery_attempts.create.attempt
36
+ touch(:delivered_at)
37
+ else
38
+ deliver_async
39
+ end
40
+ end
41
+
42
+ def attempt_count
43
+ delivery_attempts.count
44
+ end
45
+
46
+ def delivered?
47
+ delivered_at.present?
48
+ end
49
+
50
+ def still_attempting?
51
+ return false if delivered?
52
+ attempt_count < max_attempts
53
+ end
54
+
55
+ def failed?
56
+ !(delivered? || still_attempting?)
57
+ end
58
+
59
+ def name
60
+ event.short_uuid
61
+ end
62
+
63
+ def max_attempts
64
+ ATTEMPT_SCHEDULE.keys.max
65
+ end
66
+ end
@@ -0,0 +1,37 @@
1
+ module Webhooks::Outgoing::EndpointSupport
2
+ extend ActiveSupport::Concern
3
+ include Webhooks::Outgoing::UriFiltering
4
+
5
+ included do
6
+ belongs_to BulletTrain::OutgoingWebhooks.parent_association
7
+
8
+ has_many :deliveries, class_name: "Webhooks::Outgoing::Delivery", dependent: :destroy, foreign_key: :endpoint_id
9
+ has_many :events, -> { distinct }, through: :deliveries
10
+
11
+ scope :listening_for_event_type_id, ->(event_type_id) { where("event_type_ids @> ? OR event_type_ids = '[]'::jsonb", "\"#{event_type_id}\"") }
12
+
13
+ validates :name, presence: true
14
+
15
+ before_validation { url&.strip! }
16
+
17
+ validates :url, presence: true, allowed_uri: true
18
+
19
+ after_initialize do
20
+ self.event_type_ids ||= []
21
+ end
22
+
23
+ after_save :touch_parent
24
+ end
25
+
26
+ def valid_event_types
27
+ Webhooks::Outgoing::EventType.all
28
+ end
29
+
30
+ def event_types
31
+ event_type_ids.map { |id| Webhooks::Outgoing::EventType.find(id) }
32
+ end
33
+
34
+ def touch_parent
35
+ send(BulletTrain::OutgoingWebhooks.parent_association).touch
36
+ end
37
+ end
@@ -0,0 +1,45 @@
1
+ module Webhooks::Outgoing::EventSupport
2
+ extend ActiveSupport::Concern
3
+ include HasUuid
4
+
5
+ included do
6
+ belongs_to BulletTrain::OutgoingWebhooks.parent_association
7
+ belongs_to :event_type, class_name: "Webhooks::Outgoing::EventType"
8
+ belongs_to :subject, polymorphic: true
9
+ has_many :deliveries, dependent: :destroy
10
+
11
+ before_create do
12
+ self.payload = generate_payload
13
+ end
14
+ end
15
+
16
+ def generate_payload
17
+ {
18
+ event_id: uuid,
19
+ event_type: event_type_id,
20
+ subject_id: subject_id,
21
+ subject_type: subject_type,
22
+ data: data
23
+ }
24
+ end
25
+
26
+ def event_type_name
27
+ payload.dig("event_type")
28
+ end
29
+
30
+ def endpoints
31
+ send(BulletTrain::OutgoingWebhooks.parent_association).webhooks_outgoing_endpoints.listening_for_event_type_id(event_type_id)
32
+ end
33
+
34
+ def deliver
35
+ endpoints.each do |endpoint|
36
+ unless endpoint.deliveries.where(event: self).any?
37
+ endpoint.deliveries.create(event: self, endpoint_url: endpoint.url).deliver_async
38
+ end
39
+ end
40
+ end
41
+
42
+ def label_string
43
+ short_uuid
44
+ end
45
+ end
@@ -9,41 +9,59 @@ module Webhooks::Outgoing::IssuingModel
9
9
  has_many :webhooks_outgoing_events, as: :subject, class_name: "Webhooks::Outgoing::Event", dependent: :nullify
10
10
  end
11
11
 
12
- # define class methods.
13
- module ClassMethods
12
+ def skip_generate_webhook?(action)
13
+ false
14
14
  end
15
15
 
16
- def generate_webhook(action)
17
- # we can only generate webhooks for objects that return their team.
18
- return unless respond_to? :team
16
+ def parent
17
+ return unless respond_to? BulletTrain::OutgoingWebhooks.parent_association
18
+ send(BulletTrain::OutgoingWebhooks.parent_association)
19
+ end
20
+
21
+ def generate_webhook(action, async: true)
22
+ # allow individual models to opt out of generating webhooks
23
+ return if skip_generate_webhook?(action)
24
+
25
+ # we can only generate webhooks for objects that return their their team / parent.
26
+ return unless parent.present?
19
27
 
20
28
  # Try to find an event type definition for this action.
21
29
  event_type = Webhooks::Outgoing::EventType.find_by(id: "#{self.class.name.underscore}.#{action}")
22
30
 
23
31
  # If the event type is defined as one that people can be subscribed to,
24
- # and this object has a team where an associated outgoing webhooks endpoint could be registered.
25
- if event_type && team
26
-
32
+ # and this object has a parent where an associated outgoing webhooks endpoint could be registered.
33
+ if event_type
27
34
  # Only generate an event record if an endpoint is actually listening for this event type.
28
- if team.webhooks_outgoing_endpoints.listening_for_event_type_id(event_type.id).any?
29
- data = "Api::V1::#{self.class.name}Serializer".constantize.new(self).serializable_hash[:data]
30
- webhook = team.webhooks_outgoing_events.create(event_type_id: event_type.id, subject: self, data: data)
31
- webhook.deliver
35
+ if parent.endpoints_listening_for_event_type?(event_type)
36
+ if async
37
+ # serialization can be heavy so run it as a job
38
+ Webhooks::Outgoing::GenerateJob.perform_later(self, action)
39
+ else
40
+ generate_webhook_perform(action)
41
+ end
32
42
  end
33
43
  end
34
44
  end
35
45
 
46
+ def generate_webhook_perform(action)
47
+ event_type = Webhooks::Outgoing::EventType.find_by(id: "#{self.class.name.underscore}.#{action}")
48
+ data = "Api::V1::#{self.class.name}Serializer".constantize.new(self).serializable_hash[:data]
49
+ webhook = send(BulletTrain::OutgoingWebhooks.parent_association).webhooks_outgoing_events.create(event_type_id: event_type.id, subject: self, data: data)
50
+ webhook.deliver
51
+ end
52
+
36
53
  def generate_created_webhook
37
- generate_webhook("created")
54
+ generate_webhook(:created)
38
55
  end
39
56
 
40
57
  def generate_updated_webhook
41
- generate_webhook("updated")
58
+ generate_webhook(:updated)
42
59
  end
43
60
 
44
61
  def generate_deleted_webhook
45
- return false unless respond_to?(:team)
46
- return false if team&.being_destroyed?
47
- generate_webhook("deleted")
62
+ return false unless parent.present?
63
+ return false if parent.being_destroyed?
64
+
65
+ generate_webhook(:deleted, async: false)
48
66
  end
49
67
  end
@@ -8,6 +8,22 @@ module Webhooks::Outgoing::TeamSupport
8
8
  before_destroy :mark_for_destruction, prepend: true
9
9
  end
10
10
 
11
+ def should_cache_endpoints_listening_for_event_type?
12
+ true
13
+ end
14
+
15
+ def endpoints_listening_for_event_type?(event_type)
16
+ if should_cache_endpoints_listening_for_event_type?
17
+ key = "#{cache_key_with_version}/endpoints_for_event_type/#{event_type.cache_key}"
18
+
19
+ Rails.cache.fetch(key, expires_in: 24.hours, race_condition_ttl: 5.seconds) do
20
+ webhooks_outgoing_endpoints.listening_for_event_type_id(event_type.id).any?
21
+ end
22
+ else
23
+ webhooks_outgoing_endpoints.listening_for_event_type_id(event_type.id).any?
24
+ end
25
+ end
26
+
11
27
  def mark_for_destruction
12
28
  # This allows downstream logic to check whether a team is being destroyed in order to bypass webhook issuance.
13
29
  update_column(:being_destroyed, true)
@@ -0,0 +1,149 @@
1
+ require "resolv"
2
+ require "public_suffix"
3
+
4
+ module Webhooks::Outgoing::UriFiltering
5
+ extend ActiveSupport::Concern
6
+
7
+ # WEBHOOK SECURITY PRIMER
8
+ # =============================================================================
9
+ # Outgoing webhooks can be dangerous. By allowing your users to set
10
+ # up outgoing webhooks, you"re giving them permission to call arbitrary
11
+ # URLs from your server, including URLs that could represent resources
12
+ # internal to your company. Malicious actors can use this permission to
13
+ # examine your infrastructure, call internal APIs, and generally cause
14
+ # havok.
15
+
16
+ # This module attempts to block malicious actors with the following algorithm
17
+ # 1. Block anything but http and https requests
18
+ # 2. Block or allow defined hostnames, both regex and strings
19
+ # 3. Block if `custom_block_callback` returns true (args: self, uri)
20
+ # 4. Allow if `custom_allow_callback` returns true (args: self, uri)
21
+ # 5. Resolve the IP associated with the webhook"s host directly from
22
+ # the authoritative name server for the host"s domain. This IP
23
+ # is cached for the returned DNS TTL
24
+ # 6. Match the given IP against lists of allowed and blocked cidr ranges.
25
+ # The blocked list by default includes all of the defined private address
26
+ # ranges, localhost, the private IPv6 prefix, and the AWS metadata
27
+ # API endpoint.
28
+
29
+ # If at any point a URI is determined to be blocked we call `audit_callback`
30
+ # (args: self, uri) so it can be logged for auditing.
31
+
32
+ # We resolve the IP from the authoritative name server directly so we can avoid
33
+ # certain classes of DNS poisoning attacks.
34
+
35
+ # Users of this gem are _strongly_ enouraged to add additional cidr ranges
36
+ # and hostnames to the appropriate lists and/or implement `custom_block_callback`.
37
+ # At the very least you should add the public hostname that your
38
+ # application uses to the blocked_hostnames list.
39
+
40
+ class AllowedUriValidator < ActiveModel::EachValidator
41
+ def validate_each(record, attribute, value)
42
+ uri = URI.parse(value)
43
+ unless record.allowed_uri?(uri)
44
+ record.errors.add attribute, "is not an allowed uri"
45
+ end
46
+ end
47
+ end
48
+
49
+ def resolve_ip_from_authoritative(hostname)
50
+ begin
51
+ ip = IPAddr.new(hostname)
52
+ return ip.to_s
53
+ rescue IPAddr::InvalidAddressError
54
+ # this is fine, proceed with resolver path
55
+ end
56
+
57
+ cache_key = "#{cache_key_with_version}/uri_ip/#{Digest::SHA2.hexdigest(hostname)}"
58
+
59
+ cached = Rails.cache.read(cache_key)
60
+ if cached
61
+ return cached == "invalid" ? nil : cached
62
+ end
63
+
64
+ begin
65
+ # This is sort of a half-recursive DNS resolver.
66
+ # We can't implement a full recursive resolver using just Resolv::DNS so instead
67
+ # this asks a public cache for the NS record for the given domain. Then it asks
68
+ # the authoritative nameserver directly for the address and caches it according
69
+ # to the returned TTL.
70
+
71
+ config = Rails.configuration.outgoing_webhooks
72
+ ns_resolver = Resolv::DNS.new(nameserver: config[:public_resolvers])
73
+ ns_resolver.timeouts = 1
74
+
75
+ domain = PublicSuffix.domain(hostname)
76
+ authoritative = ns_resolver.getresource(domain, Resolv::DNS::Resource::IN::NS)
77
+
78
+ authoritative_resolver = Resolv::DNS.new(nameserver: [authoritative.name.to_s])
79
+ authoritative_resolver.timeouts = 1
80
+
81
+ resource = authoritative_resolver.getresource(hostname, Resolv::DNS::Resource::IN::A)
82
+ Rails.cache.write(cache_key, resource.address.to_s, expires_in: resource.ttl, race_condition_ttl: 5)
83
+ resource.address.to_s
84
+ rescue IPAddr::InvalidAddressError, ArgumentError # standard:disable Lint/ShadowedException
85
+ Rails.cache.write(cache_key, "invalid", expires_in: 10.minutes, race_condition_ttl: 5)
86
+ nil
87
+ end
88
+ end
89
+
90
+ def allowed_uri?(uri)
91
+ unless _allowed_uri?(uri)
92
+ config = Rails.configuration.outgoing_webhooks
93
+ if config[:audit_callback].present?
94
+ config[:audit_callback].call(self, uri)
95
+ end
96
+ return false
97
+ end
98
+
99
+ true
100
+ end
101
+
102
+ def _allowed_uri?(uri)
103
+ config = Rails.configuration.outgoing_webhooks
104
+ hostname = uri.hostname.downcase
105
+
106
+ return false unless config[:allowed_schemes].include?(uri.scheme)
107
+
108
+ config[:blocked_hostnames].each do |blocked|
109
+ if blocked.is_a?(Regexp)
110
+ return false if blocked.match?(hostname)
111
+ end
112
+
113
+ return false if blocked == hostname
114
+ end
115
+
116
+ config[:allowed_hostnames].each do |allowed|
117
+ if allowed.is_a?(Regexp)
118
+ return true if allowed.match?(hostname)
119
+ end
120
+
121
+ return true if allowed == hostname
122
+ end
123
+
124
+ if config[:custom_allow_callback].present?
125
+ return true if config[:custom_allow_callback].call(self, uri)
126
+ end
127
+
128
+ if config[:custom_block_callback].present?
129
+ return false if config[:custom_block_callback].call(self, uri)
130
+ end
131
+
132
+ resolved_ip = resolve_ip_from_authoritative(hostname)
133
+ return false if resolved_ip.nil?
134
+
135
+ begin
136
+ config[:allowed_cidrs].each do |cidr|
137
+ return true if IPAddr.new(cidr).include?(resolved_ip)
138
+ end
139
+
140
+ config[:blocked_cidrs].each do |cidr|
141
+ return false if IPAddr.new(cidr).include?(resolved_ip)
142
+ end
143
+ rescue IPAddr::InvalidAddressError
144
+ return false
145
+ end
146
+
147
+ true
148
+ end
149
+ end
@@ -1,21 +1,9 @@
1
- class Webhooks::Outgoing::Delivery < ApplicationRecord
1
+ class Webhooks::Outgoing::Delivery < BulletTrain::OutgoingWebhooks.base_class.constantize
2
+ include Webhooks::Outgoing::DeliverySupport
2
3
  # 🚅 add concerns above.
3
4
 
4
- belongs_to :endpoint, class_name: "Webhooks::Outgoing::Endpoint"
5
- belongs_to :event, class_name: "Webhooks::Outgoing::Event"
6
- has_one :team, through: :endpoint
7
-
8
- ATTEMPT_SCHEDULE = {
9
- 1 => 15.seconds,
10
- 2 => 1.minute,
11
- 3 => 5.minutes,
12
- 4 => 15.minutes,
13
- 5 => 1.hour,
14
- }
15
-
16
5
  # 🚅 add belongs_to associations above.
17
6
 
18
- has_many :delivery_attempts, class_name: "Webhooks::Outgoing::DeliveryAttempt", dependent: :destroy, foreign_key: :delivery_id
19
7
  # 🚅 add has_many associations above.
20
8
 
21
9
  # 🚅 add has_one associations above.
@@ -28,52 +16,5 @@ class Webhooks::Outgoing::Delivery < ApplicationRecord
28
16
 
29
17
  # 🚅 add delegations above.
30
18
 
31
- def label_string
32
- event.short_uuid
33
- end
34
-
35
- def next_reattempt_delay
36
- ATTEMPT_SCHEDULE[attempt_count]
37
- end
38
-
39
- def deliver_async
40
- if still_attempting?
41
- Webhooks::Outgoing::DeliveryJob.set(wait: next_reattempt_delay).perform_later(self)
42
- end
43
- end
44
-
45
- def deliver
46
- if delivery_attempts.create.attempt
47
- touch(:delivered_at)
48
- else
49
- deliver_async
50
- end
51
- end
52
-
53
- def attempt_count
54
- delivery_attempts.count
55
- end
56
-
57
- def delivered?
58
- delivered_at.present?
59
- end
60
-
61
- def still_attempting?
62
- return false if delivered?
63
- attempt_count < max_attempts
64
- end
65
-
66
- def failed?
67
- !(delivered? || still_attempting?)
68
- end
69
-
70
- def name
71
- event.short_uuid
72
- end
73
-
74
- def max_attempts
75
- ATTEMPT_SCHEDULE.keys.max
76
- end
77
-
78
19
  # 🚅 add methods above.
79
20
  end
@@ -1,51 +1,7 @@
1
- class Webhooks::Outgoing::DeliveryAttempt < ApplicationRecord
1
+ class Webhooks::Outgoing::DeliveryAttempt < BulletTrain::OutgoingWebhooks.base_class.constantize
2
+ include Webhooks::Outgoing::DeliveryAttemptSupport
2
3
  # 🚅 add concerns above.
3
4
 
4
- belongs_to :delivery
5
- has_one :team, through: :delivery
6
- scope :successful, -> { where(response_code: 200) }
7
-
8
- before_create do
9
- self.attempt_number = delivery.attempt_count + 1
10
- end
11
-
12
- def still_attempting?
13
- error_message.nil? && response_code.nil?
14
- end
15
-
16
- def successful?
17
- [200, 201, 202, 203, 204, 205, 206, 207, 226].include?(response_code)
18
- end
19
-
20
- def failed?
21
- !(successful? || still_attempting?)
22
- end
23
-
24
- def attempt
25
- uri = URI.parse(delivery.endpoint_url)
26
- http = Net::HTTP.new(uri.host, uri.port)
27
- http.use_ssl = true if uri.scheme == "https"
28
- request = Net::HTTP::Post.new(uri.path)
29
- request.add_field("Content-Type", "application/json")
30
- request.body = delivery.event.payload.to_json
31
-
32
- begin
33
- response = http.request(request)
34
- self.response_message = response.message
35
- self.response_code = response.code
36
- self.response_body = response.body
37
- rescue Exception => exception
38
- self.response_code = 0
39
- self.error_message = exception.message
40
- end
41
-
42
- save
43
- successful?
44
- end
45
-
46
- def label_string
47
- "#{attempt_number.ordinalize} Attempt"
48
- end
49
5
  # 🚅 add belongs_to associations above.
50
6
 
51
7
  # 🚅 add has_many associations above.
@@ -54,7 +10,6 @@ class Webhooks::Outgoing::DeliveryAttempt < ApplicationRecord
54
10
 
55
11
  # 🚅 add scopes above.
56
12
 
57
- validates :response_code, presence: true
58
13
  # 🚅 add validations above.
59
14
 
60
15
  # 🚅 add callbacks above.
@@ -1,32 +1,20 @@
1
- class Webhooks::Outgoing::Endpoint < ApplicationRecord
1
+ class Webhooks::Outgoing::Endpoint < BulletTrain::OutgoingWebhooks.base_class.constantize
2
+ include Webhooks::Outgoing::EndpointSupport
2
3
  # 🚅 add concerns above.
3
4
 
4
- belongs_to :team
5
5
  # 🚅 add belongs_to associations above.
6
6
 
7
- has_many :deliveries, class_name: "Webhooks::Outgoing::Delivery", dependent: :destroy, foreign_key: :endpoint_id
8
- has_many :events, -> { distinct }, through: :deliveries
9
7
  # 🚅 add has_many associations above.
10
8
 
11
9
  # 🚅 add has_one associations above.
12
10
 
13
- scope :listening_for_event_type_id, ->(event_type_id) { where("event_type_ids @> ? OR event_type_ids = '[]'::jsonb", "\"#{event_type_id}\"") }
14
11
  # 🚅 add scopes above.
15
12
 
16
- validates :name, presence: true
17
13
  # 🚅 add validations above.
18
14
 
19
15
  # 🚅 add callbacks above.
20
16
 
21
17
  # 🚅 add delegations above.
22
18
 
23
- def valid_event_types
24
- Webhooks::Outgoing::EventType.all
25
- end
26
-
27
- def event_types
28
- event_type_ids.map { |id| Webhooks::Outgoing::EventType.find(id) }
29
- end
30
-
31
19
  # 🚅 add methods above.
32
20
  end
@@ -1,41 +1,3 @@
1
- class Webhooks::Outgoing::Event < ApplicationRecord
2
- include HasUuid
3
- belongs_to :team
4
- belongs_to :event_type, class_name: "Webhooks::Outgoing::EventType"
5
- belongs_to :subject, polymorphic: true
6
- has_many :deliveries, dependent: :destroy
7
-
8
- before_create do
9
- self.payload = generate_payload
10
- end
11
-
12
- def generate_payload
13
- {
14
- event_id: uuid,
15
- event_type: event_type_id,
16
- subject_id: subject_id,
17
- subject_type: subject_type,
18
- data: data
19
- }
20
- end
21
-
22
- def event_type_name
23
- payload.dig("event_type")
24
- end
25
-
26
- def endpoints
27
- team.webhooks_outgoing_endpoints.listening_for_event_type_id(event_type_id)
28
- end
29
-
30
- def deliver
31
- endpoints.each do |endpoint|
32
- unless endpoint.deliveries.where(event: self).any?
33
- endpoint.deliveries.create(event: self, endpoint_url: endpoint.url).deliver_async
34
- end
35
- end
36
- end
37
-
38
- def label_string
39
- short_uuid
40
- end
1
+ class Webhooks::Outgoing::Event < BulletTrain::OutgoingWebhooks.base_class.constantize
2
+ include Webhooks::Outgoing::EventSupport
41
3
  end
@@ -1,6 +1,6 @@
1
- <% if can? :read, Webhooks::Outgoing::Delivery.new(team: current_team) %>
1
+ <% if can? :read, Webhooks::Outgoing::Delivery.new(BulletTrain::OutgoingWebhooks.parent_association => send(BulletTrain::OutgoingWebhooks.current_parent_method)) %>
2
2
  <%= render 'account/shared/menu/item', {
3
- url: main_app.polymorphic_path([:account, current_team, :webhooks_outgoing_deliveries]),
3
+ url: main_app.polymorphic_path([:account, send(BulletTrain::OutgoingWebhooks.current_parent_method), :webhooks_outgoing_deliveries]),
4
4
  label: t('webhooks/outgoing/deliveries.navigation.label'),
5
5
  } do |p| %>
6
6
  <% p.content_for :icon do %>
@@ -1,6 +1,6 @@
1
- <% if can? :read, Webhooks::Outgoing::DeliveryAttempt.new(team: current_team) %>
1
+ <% if can? :read, Webhooks::Outgoing::DeliveryAttempt.new(BulletTrain::OutgoingWebhooks.parent_association => send(BulletTrain::OutgoingWebhooks.current_parent_method)) %>
2
2
  <%= render 'account/shared/menu/item', {
3
- url: main_app.polymorphic_path([:account, current_team, :webhooks_outgoing_delivery_attempts]),
3
+ url: main_app.polymorphic_path([:account, send(BulletTrain::OutgoingWebhooks.current_parent_method), :webhooks_outgoing_delivery_attempts]),
4
4
  label: t('webhooks/outgoing/delivery_attempts.navigation.label'),
5
5
  } do |p| %>
6
6
  <% p.content_for :icon do %>
@@ -1,5 +1,5 @@
1
1
  <% endpoint ||= @endpoint %>
2
- <% team ||= @team || endpoint&.team %>
2
+ <% team ||= @parent || endpoint&.send(BulletTrain::OutgoingWebhooks.parent_association) %>
3
3
  <%= render 'account/teams/breadcrumbs', team: team %>
4
4
  <%= render 'account/shared/breadcrumb', label: t('.label'), url: [:account, team, :webhooks_outgoing_endpoints] %>
5
5
  <% if endpoint&.persisted? %>
@@ -1,4 +1,4 @@
1
- <%= form_with model: endpoint, url: (endpoint.persisted? ? [:account, endpoint] : [:account, @team, :webhooks_outgoing_endpoints]), local: true, class: 'form' do |form| %>
1
+ <%= form_with model: endpoint, url: (endpoint.persisted? ? [:account, endpoint] : [:account, @parent, :webhooks_outgoing_endpoints]), local: true, class: 'form' do |form| %>
2
2
  <%= render 'account/shared/forms/errors', form: form %>
3
3
 
4
4
  <% with_field_settings form: form do %>
@@ -14,7 +14,7 @@
14
14
  <% if form.object.persisted? %>
15
15
  <%= link_to t('global.buttons.cancel'), [:account, endpoint], class: "button-secondary" %>
16
16
  <% else %>
17
- <%= link_to t('global.buttons.cancel'), [:account, @team, :webhooks_outgoing_endpoints], class: "button-secondary" %>
17
+ <%= link_to t('global.buttons.cancel'), [:account, @parent, :webhooks_outgoing_endpoints], class: "button-secondary" %>
18
18
  <% end %>
19
19
  </div>
20
20
 
@@ -1,4 +1,4 @@
1
- <% team = @team || @team %>
1
+ <% team = @parent %>
2
2
  <% context ||= team %>
3
3
  <% collection ||= :webhooks_outgoing_endpoints %>
4
4
  <% hide_actions ||= false %>
@@ -49,8 +49,8 @@
49
49
  <% p.content_for :actions do %>
50
50
  <% unless hide_actions %>
51
51
  <% if context == team %>
52
- <% if can? :create, Webhooks::Outgoing::Endpoint.new(team: team) %>
53
- <%= link_to t('.buttons.new'), [:new, :account, team, :webhooks_outgoing_endpoint], class: "#{first_button_primary(:webhooks_outgoing_endpoint)} new" %>
52
+ <% if can? :create, Webhooks::Outgoing::Endpoint.new(BulletTrain::OutgoingWebhooks.parent_association => @parent) %>
53
+ <%= link_to t('.buttons.new'), [:new, :account, @parent, :webhooks_outgoing_endpoint], class: "#{first_button_primary(:webhooks_outgoing_endpoint)} new" %>
54
54
  <% end %>
55
55
  <% end %>
56
56
 
@@ -1,6 +1,6 @@
1
- <% if can? :read, Webhooks::Outgoing::Endpoint.new(team: current_team) %>
1
+ <% if can? :read, Webhooks::Outgoing::Endpoint.new(BulletTrain::OutgoingWebhooks.parent_association => send(BulletTrain::OutgoingWebhooks.current_parent_method)) %>
2
2
  <%= render 'account/shared/menu/item', {
3
- url: main_app.polymorphic_path([:account, current_team, :webhooks_outgoing_endpoints]),
3
+ url: main_app.polymorphic_path([:account, send(BulletTrain::OutgoingWebhooks.current_parent_method), :webhooks_outgoing_endpoints]),
4
4
  label: t('webhooks/outgoing/endpoints.navigation.label'),
5
5
  } do |p| %>
6
6
  <% p.content_for :icon do %>
@@ -32,7 +32,7 @@
32
32
  <% p.content_for :actions do %>
33
33
  <%= link_to t('.buttons.edit'), [:edit, :account, @endpoint], class: first_button_primary if can? :edit, @endpoint %>
34
34
  <%= button_to t('.buttons.destroy'), [:account, @endpoint], method: :delete, class: first_button_primary, data: { confirm: t('.buttons.confirmations.destroy', model_locales(@endpoint)) } if can? :destroy, @endpoint %>
35
- <%= link_to t('global.buttons.back'), [:account, @team, :webhooks_outgoing_endpoints], class: first_button_primary %>
35
+ <%= link_to t('global.buttons.back'), [:account, @parent, :webhooks_outgoing_endpoints], class: first_button_primary %>
36
36
  <% end %>
37
37
  <% end %>
38
38
 
data/config/routes.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  Rails.application.routes.draw do
2
2
  namespace :account do
3
3
  shallow do
4
- resources :teams do
4
+ resources BulletTrain::OutgoingWebhooks.parent_resource do
5
5
  namespace :webhooks do
6
6
  namespace :outgoing do
7
7
  resources :events
@@ -1,8 +1,33 @@
1
1
  module BulletTrain
2
2
  module OutgoingWebhooks
3
3
  class Engine < ::Rails::Engine
4
+ config.before_configuration do
5
+ default_blocked_cidrs = %w[
6
+ 10.0.0.0/8
7
+ 172.16.0.0/12
8
+ 192.168.0.0/16
9
+ 100.64.0.0/10
10
+ 127.0.0.0/8
11
+ 169.254.169.254/32
12
+ fc00::/7
13
+ ::1
14
+ ]
15
+
16
+ config.outgoing_webhooks = {
17
+ blocked_cidrs: default_blocked_cidrs,
18
+ allowed_cidrs: [],
19
+ blocked_hostnames: %w[localhost],
20
+ allowed_hostnames: [],
21
+ public_resolvers: %w[8.8.8.8 1.1.1.1],
22
+ allowed_schemes: %w[http https],
23
+ custom_block_callback: nil,
24
+ custom_allow_callback: nil,
25
+ audit_callback: ->(obj, uri) { Rails.logger.error("BlockedURI obj=#{obj.persisted? ? obj.to_global_id : "New #{obj.class}"} uri=#{uri}") }
26
+ }
27
+ end
28
+
4
29
  initializer "bullet_train.outgoing_webhooks.register_api_endpoints" do |app|
5
- if BulletTrain::Api
30
+ if Object.const_defined?("BulletTrain::Api")
6
31
  BulletTrain::Api.endpoints << "Api::V1::Webhooks::Outgoing::EndpointsEndpoint"
7
32
  BulletTrain::Api.endpoints << "Api::V1::Webhooks::Outgoing::DeliveriesEndpoint"
8
33
  BulletTrain::Api.endpoints << "Api::V1::Webhooks::Outgoing::DeliveryAttemptsEndpoint"
@@ -1,5 +1,5 @@
1
1
  module BulletTrain
2
2
  module OutgoingWebhooks
3
- VERSION = "1.0.4"
3
+ VERSION = "1.0.5"
4
4
  end
5
5
  end
@@ -3,6 +3,31 @@ require "bullet_train/outgoing_webhooks/engine"
3
3
 
4
4
  module BulletTrain
5
5
  module OutgoingWebhooks
6
- # Your code goes here...
6
+ def self.default_for(klass, method, default_value)
7
+ klass.respond_to?(method) ? klass.send(method) || default_value : default_value
8
+ end
9
+
10
+ mattr_accessor :parent_class, default: default_for(BulletTrain, :parent_class, "Team")
11
+ mattr_accessor :base_class, default: default_for(BulletTrain, :base_class, "ApplicationRecord")
12
+
13
+ def self.parent_association
14
+ parent_class.underscore.to_sym
15
+ end
16
+
17
+ def self.parent_resource
18
+ parent_class.underscore.pluralize.to_sym
19
+ end
20
+
21
+ def self.parent_class_specified?
22
+ parent_class != "Team"
23
+ end
24
+
25
+ def self.current_parent_method
26
+ "current_#{parent_association}"
27
+ end
28
+
29
+ def self.parent_association_id
30
+ "#{parent_association}_id".to_sym
31
+ end
7
32
  end
8
33
  end
metadata CHANGED
@@ -1,29 +1,57 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bullet_train-outgoing_webhooks
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.4
4
+ version: 1.0.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Culver
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2022-02-16 00:00:00.000000000 Z
11
+ date: 2022-08-24 00:00:00.000000000 Z
12
12
  dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: standard
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
13
27
  - !ruby/object:Gem::Dependency
14
28
  name: rails
15
29
  requirement: !ruby/object:Gem::Requirement
16
30
  requirements:
17
31
  - - ">="
18
32
  - !ruby/object:Gem::Version
19
- version: 7.0.0
33
+ version: 6.0.0
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: 6.0.0
41
+ - !ruby/object:Gem::Dependency
42
+ name: public_suffix
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
20
48
  type: :runtime
21
49
  prerelease: false
22
50
  version_requirements: !ruby/object:Gem::Requirement
23
51
  requirements:
24
52
  - - ">="
25
53
  - !ruby/object:Gem::Version
26
- version: 7.0.0
54
+ version: '0'
27
55
  description: Allow users of your Rails application to subscribe and receive webhooks
28
56
  when activity takes place in your application.
29
57
  email:
@@ -43,8 +71,14 @@ files:
43
71
  - app/controllers/api/v1/webhooks/outgoing/delivery_attempts_endpoint.rb
44
72
  - app/controllers/api/v1/webhooks/outgoing/endpoints_endpoint.rb
45
73
  - app/jobs/webhooks/outgoing/delivery_job.rb
74
+ - app/jobs/webhooks/outgoing/generate_job.rb
75
+ - app/models/concerns/webhooks/outgoing/delivery_attempt_support.rb
76
+ - app/models/concerns/webhooks/outgoing/delivery_support.rb
77
+ - app/models/concerns/webhooks/outgoing/endpoint_support.rb
78
+ - app/models/concerns/webhooks/outgoing/event_support.rb
46
79
  - app/models/concerns/webhooks/outgoing/issuing_model.rb
47
80
  - app/models/concerns/webhooks/outgoing/team_support.rb
81
+ - app/models/concerns/webhooks/outgoing/uri_filtering.rb
48
82
  - app/models/webhooks.rb
49
83
  - app/models/webhooks/outgoing.rb
50
84
  - app/models/webhooks/outgoing/delivery.rb
@@ -141,7 +175,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
141
175
  - !ruby/object:Gem::Version
142
176
  version: '0'
143
177
  requirements: []
144
- rubygems_version: 3.2.22
178
+ rubygems_version: 3.3.7
145
179
  signing_key:
146
180
  specification_version: 4
147
181
  summary: Allow users of your Rails application to subscribe and receive webhooks when