bullet_train-outgoing_webhooks 1.0.4 → 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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