hooksniff 0.1.0 → 1.0.0
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.
- checksums.yaml +4 -4
- data/Gemfile +7 -0
- data/Gemfile.lock +56 -0
- data/README.md +24 -197
- data/Rakefile +2 -0
- data/hooksniff.gemspec +48 -0
- data/lib/hooksniff/api/authentication.rb +36 -0
- data/lib/hooksniff/api/endpoint.rb +102 -0
- data/lib/hooksniff/api/event_type.rb +66 -0
- data/lib/hooksniff/api/health.rb +16 -0
- data/lib/hooksniff/api/message.rb +48 -0
- data/lib/hooksniff/api/message_attempt.rb +38 -0
- data/lib/hooksniff/api/statistics.rb +37 -0
- data/lib/hooksniff/api_error.rb +48 -0
- data/lib/hooksniff/errors.rb +107 -21
- data/lib/hooksniff/hooksniff.rb +36 -0
- data/lib/hooksniff/hooksniff_http_client.rb +128 -0
- data/lib/hooksniff/http_error_out.rb +18 -0
- data/lib/hooksniff/http_validation_error.rb +18 -0
- data/lib/hooksniff/internal.rb +7 -0
- data/lib/hooksniff/models/aggregate_event_types_out.rb +59 -0
- data/lib/hooksniff/models/endpoint_created_event.rb +50 -0
- data/lib/hooksniff/models/endpoint_created_event_data.rb +63 -0
- data/lib/hooksniff/models/endpoint_deleted_event.rb +50 -0
- data/lib/hooksniff/models/endpoint_deleted_event_data.rb +63 -0
- data/lib/hooksniff/models/endpoint_disabled_event.rb +53 -0
- data/lib/hooksniff/models/endpoint_disabled_event_data.rb +69 -0
- data/lib/hooksniff/models/endpoint_enabled_event.rb +50 -0
- data/lib/hooksniff/models/endpoint_enabled_event_data.rb +63 -0
- data/lib/hooksniff/models/endpoint_headers_in.rb +46 -0
- data/lib/hooksniff/models/endpoint_headers_out.rb +52 -0
- data/lib/hooksniff/models/endpoint_headers_patch_in.rb +53 -0
- data/lib/hooksniff/models/endpoint_in.rb +102 -0
- data/lib/hooksniff/models/endpoint_out.rb +104 -0
- data/lib/hooksniff/models/endpoint_patch.rb +97 -0
- data/lib/hooksniff/models/endpoint_secret_out.rb +50 -0
- data/lib/hooksniff/models/endpoint_secret_rotate_in.rb +53 -0
- data/lib/hooksniff/models/endpoint_update.rb +90 -0
- data/lib/hooksniff/models/endpoint_updated_event.rb +50 -0
- data/lib/hooksniff/models/endpoint_updated_event_data.rb +63 -0
- data/lib/hooksniff/models/event_in.rb +50 -0
- data/lib/hooksniff/models/event_out.rb +53 -0
- data/lib/hooksniff/models/event_type_in.rb +80 -0
- data/lib/hooksniff/models/event_type_out.rb +87 -0
- data/lib/hooksniff/models/event_type_patch.rb +66 -0
- data/lib/hooksniff/models/event_type_update.rb +67 -0
- data/lib/hooksniff/models/list_response_endpoint_out.rb +58 -0
- data/lib/hooksniff/models/list_response_event_type_out.rb +58 -0
- data/lib/hooksniff/models/list_response_message_attempt_out.rb +58 -0
- data/lib/hooksniff/models/list_response_message_out.rb +58 -0
- data/lib/hooksniff/models/message_attempt_exhausted_event.rb +53 -0
- data/lib/hooksniff/models/message_attempt_exhausted_event_data.rb +70 -0
- data/lib/hooksniff/models/message_attempt_failed_data.rb +56 -0
- data/lib/hooksniff/models/message_attempt_failing_event.rb +54 -0
- data/lib/hooksniff/models/message_attempt_failing_event_data.rb +70 -0
- data/lib/hooksniff/models/message_attempt_log.rb +112 -0
- data/lib/hooksniff/models/message_attempt_log_event.rb +53 -0
- data/lib/hooksniff/models/message_attempt_out.rb +96 -0
- data/lib/hooksniff/models/message_attempt_recovered_event.rb +53 -0
- data/lib/hooksniff/models/message_attempt_recovered_event_data.rb +70 -0
- data/lib/hooksniff/models/message_attempt_trigger_type.rb +33 -0
- data/lib/hooksniff/models/message_endpoint_out.rb +112 -0
- data/lib/hooksniff/models/message_in.rb +100 -0
- data/lib/hooksniff/models/message_out.rb +71 -0
- data/lib/hooksniff/models/message_status.rb +39 -0
- data/lib/hooksniff/models/message_status_text.rb +32 -0
- data/lib/hooksniff/models/ordering.rb +30 -0
- data/lib/hooksniff/models/status_code_class.rb +41 -0
- data/lib/hooksniff/util.rb +69 -0
- data/lib/hooksniff/validation_error.rb +28 -0
- data/lib/hooksniff/version.rb +1 -1
- data/lib/hooksniff/webhook.rb +84 -0
- data/lib/hooksniff.rb +71 -12
- data/test/test_hooksniff.rb +86 -0
- metadata +124 -31
- data/lib/hooksniff/client.rb +0 -213
- data/lib/hooksniff/models.rb +0 -136
- data/lib/hooksniff/verification.rb +0 -134
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# This file is @generated
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module HookSniff
|
|
6
|
+
class MessageOut
|
|
7
|
+
# List of free-form identifiers that endpoints can filter by
|
|
8
|
+
attr_accessor :channels
|
|
9
|
+
attr_accessor :deliver_at
|
|
10
|
+
# Optional unique identifier for the message
|
|
11
|
+
attr_accessor :event_id
|
|
12
|
+
# The event type's name
|
|
13
|
+
attr_accessor :event_type
|
|
14
|
+
# The Message's ID.
|
|
15
|
+
attr_accessor :id
|
|
16
|
+
attr_accessor :payload
|
|
17
|
+
attr_accessor :tags
|
|
18
|
+
attr_accessor :timestamp
|
|
19
|
+
|
|
20
|
+
ALL_FIELD ||= ["channels", "deliver_at", "event_id", "event_type", "id", "payload", "tags", "timestamp"].freeze
|
|
21
|
+
private_constant :ALL_FIELD
|
|
22
|
+
|
|
23
|
+
def initialize(attributes = {})
|
|
24
|
+
unless attributes.is_a?(Hash)
|
|
25
|
+
fail(ArgumentError, "The input argument (attributes) must be a hash in `HookSniff::MessageOut` new method")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
attributes.each do |k, v|
|
|
29
|
+
unless ALL_FIELD.include?(k.to_s)
|
|
30
|
+
fail(ArgumentError, "The field #{k} is not part of HookSniff::MessageOut")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
instance_variable_set("@#{k}", v)
|
|
34
|
+
instance_variable_set("@__#{k}_is_defined", true)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.deserialize(attributes = {})
|
|
39
|
+
attributes = attributes.transform_keys(&:to_s)
|
|
40
|
+
attrs = Hash.new
|
|
41
|
+
attrs["channels"] = attributes["channels"]
|
|
42
|
+
attrs["deliver_at"] = DateTime.rfc3339(attributes["deliverAt"]).to_time if attributes["deliverAt"]
|
|
43
|
+
attrs["event_id"] = attributes["eventId"]
|
|
44
|
+
attrs["event_type"] = attributes["eventType"]
|
|
45
|
+
attrs["id"] = attributes["id"]
|
|
46
|
+
attrs["payload"] = attributes["payload"]
|
|
47
|
+
attrs["tags"] = attributes["tags"]
|
|
48
|
+
attrs["timestamp"] = DateTime.rfc3339(attributes["timestamp"]).to_time
|
|
49
|
+
new(attrs)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def serialize
|
|
53
|
+
out = Hash.new
|
|
54
|
+
out["channels"] = HookSniff::serialize_primitive(@channels) if @channels
|
|
55
|
+
out["deliverAt"] = HookSniff::serialize_primitive(@deliver_at) if @deliver_at
|
|
56
|
+
out["eventId"] = HookSniff::serialize_primitive(@event_id) if @event_id
|
|
57
|
+
out["eventType"] = HookSniff::serialize_primitive(@event_type) if @event_type
|
|
58
|
+
out["id"] = HookSniff::serialize_primitive(@id) if @id
|
|
59
|
+
out["payload"] = HookSniff::serialize_primitive(@payload) if @payload
|
|
60
|
+
out["tags"] = HookSniff::serialize_primitive(@tags) if @tags
|
|
61
|
+
out["timestamp"] = HookSniff::serialize_primitive(@timestamp) if @timestamp
|
|
62
|
+
out
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Serializes the object to a json string
|
|
66
|
+
# @return String
|
|
67
|
+
def to_json
|
|
68
|
+
JSON.dump(serialize)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# This file is @generated
|
|
3
|
+
module HookSniff
|
|
4
|
+
# The sending status of the message:
|
|
5
|
+
#
|
|
6
|
+
# - Success = 0
|
|
7
|
+
# - Pending = 1
|
|
8
|
+
# - Fail = 2
|
|
9
|
+
# - Sending = 3
|
|
10
|
+
# - Canceled = 4
|
|
11
|
+
class MessageStatus
|
|
12
|
+
SUCCESS = 0.freeze
|
|
13
|
+
PENDING = 1.freeze
|
|
14
|
+
FAIL = 2.freeze
|
|
15
|
+
SENDING = 3.freeze
|
|
16
|
+
CANCELED = 4.freeze
|
|
17
|
+
|
|
18
|
+
def self.all_vars
|
|
19
|
+
@all_vars ||= [SUCCESS, PENDING, FAIL, SENDING, CANCELED].freeze
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def initialize(value)
|
|
23
|
+
unless MessageStatus.all_vars.include?(value)
|
|
24
|
+
raise "Invalid ENUM value '#{value}' for class #MessageStatus"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
@value = value
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.deserialize(value)
|
|
31
|
+
return value if MessageStatus.all_vars.include?(value)
|
|
32
|
+
raise "Invalid ENUM value '#{value}' for class #MessageStatus"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def serialize
|
|
36
|
+
@value
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# This file is @generated
|
|
3
|
+
module HookSniff
|
|
4
|
+
class MessageStatusText
|
|
5
|
+
SUCCESS = "success".freeze
|
|
6
|
+
PENDING = "pending".freeze
|
|
7
|
+
FAIL = "fail".freeze
|
|
8
|
+
SENDING = "sending".freeze
|
|
9
|
+
CANCELED = "canceled".freeze
|
|
10
|
+
|
|
11
|
+
def self.all_vars
|
|
12
|
+
@all_vars ||= [SUCCESS, PENDING, FAIL, SENDING, CANCELED].freeze
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def initialize(value)
|
|
16
|
+
unless MessageStatusText.all_vars.include?(value)
|
|
17
|
+
raise "Invalid ENUM value '#{value}' for class #MessageStatusText"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
@value = value
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.deserialize(value)
|
|
24
|
+
return value if MessageStatusText.all_vars.include?(value)
|
|
25
|
+
raise "Invalid ENUM value '#{value}' for class #MessageStatusText"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def serialize
|
|
29
|
+
@value
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# This file is @generated
|
|
3
|
+
module HookSniff
|
|
4
|
+
# Defines the ordering in a listing of results.
|
|
5
|
+
class Ordering
|
|
6
|
+
ASCENDING = "ascending".freeze
|
|
7
|
+
DESCENDING = "descending".freeze
|
|
8
|
+
|
|
9
|
+
def self.all_vars
|
|
10
|
+
@all_vars ||= [ASCENDING, DESCENDING].freeze
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def initialize(value)
|
|
14
|
+
unless Ordering.all_vars.include?(value)
|
|
15
|
+
raise "Invalid ENUM value '#{value}' for class #Ordering"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
@value = value
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.deserialize(value)
|
|
22
|
+
return value if Ordering.all_vars.include?(value)
|
|
23
|
+
raise "Invalid ENUM value '#{value}' for class #Ordering"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def serialize
|
|
27
|
+
@value
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# This file is @generated
|
|
3
|
+
module HookSniff
|
|
4
|
+
# The different classes of HTTP status codes:
|
|
5
|
+
#
|
|
6
|
+
# - CodeNone = 0
|
|
7
|
+
# - Code1xx = 100
|
|
8
|
+
# - Code2xx = 200
|
|
9
|
+
# - Code3xx = 300
|
|
10
|
+
# - Code4xx = 400
|
|
11
|
+
# - Code5xx = 500
|
|
12
|
+
class StatusCodeClass
|
|
13
|
+
CODE_NONE = 0.freeze
|
|
14
|
+
CODE1XX = 100.freeze
|
|
15
|
+
CODE2XX = 200.freeze
|
|
16
|
+
CODE3XX = 300.freeze
|
|
17
|
+
CODE4XX = 400.freeze
|
|
18
|
+
CODE5XX = 500.freeze
|
|
19
|
+
|
|
20
|
+
def self.all_vars
|
|
21
|
+
@all_vars ||= [CODE_NONE, CODE1XX, CODE2XX, CODE3XX, CODE4XX, CODE5XX].freeze
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialize(value)
|
|
25
|
+
unless StatusCodeClass.all_vars.include?(value)
|
|
26
|
+
raise "Invalid ENUM value '#{value}' for class #StatusCodeClass"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
@value = value
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.deserialize(value)
|
|
33
|
+
return value if StatusCodeClass.all_vars.include?(value)
|
|
34
|
+
raise "Invalid ENUM value '#{value}' for class #StatusCodeClass"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def serialize
|
|
38
|
+
@value
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "date"
|
|
3
|
+
|
|
4
|
+
# Constant time string comparison, for fixed length strings.
|
|
5
|
+
# Code borrowed from ActiveSupport
|
|
6
|
+
# https://github.com/rails/rails/blob/75ac626c4e21129d8296d4206a1960563cc3d4aa/activesupport/lib/active_support/security_utils.rb#L33
|
|
7
|
+
#
|
|
8
|
+
# The values compared should be of fixed length, such as strings
|
|
9
|
+
# that have already been processed by HMAC. Raises in case of length mismatch.
|
|
10
|
+
module HookSniff
|
|
11
|
+
if defined?(OpenSSL.fixed_length_secure_compare)
|
|
12
|
+
def fixed_length_secure_compare(a, b)
|
|
13
|
+
OpenSSL.fixed_length_secure_compare(a, b)
|
|
14
|
+
end
|
|
15
|
+
else
|
|
16
|
+
def fixed_length_secure_compare(a, b)
|
|
17
|
+
raise ArgumentError, "string length mismatch." unless a.bytesize == b.bytesize
|
|
18
|
+
|
|
19
|
+
l = a.unpack("C#{a.bytesize}")
|
|
20
|
+
|
|
21
|
+
res = 0
|
|
22
|
+
b.each_byte { |byte| res |= byte ^ l.shift }
|
|
23
|
+
res == 0
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
module_function :fixed_length_secure_compare
|
|
28
|
+
|
|
29
|
+
# Secure string comparison for strings of variable length.
|
|
30
|
+
#
|
|
31
|
+
# While a timing attack would not be able to discern the content of
|
|
32
|
+
# a secret compared via secure_compare, it is possible to determine
|
|
33
|
+
# the secret length. This should be considered when using secure_compare
|
|
34
|
+
# to compare weak, short secrets to user input.
|
|
35
|
+
def secure_compare(a, b)
|
|
36
|
+
a.length == b.length && fixed_length_secure_compare(a, b)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
module_function :secure_compare
|
|
40
|
+
|
|
41
|
+
def serialize_primitive(v)
|
|
42
|
+
if v.kind_of?(Time)
|
|
43
|
+
v.utc.to_datetime.rfc3339
|
|
44
|
+
else
|
|
45
|
+
v
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
module_function :serialize_primitive
|
|
50
|
+
|
|
51
|
+
def deserialize_date(v)
|
|
52
|
+
DateTime.rfc3339(v)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
module_function :deserialize_date
|
|
56
|
+
|
|
57
|
+
def serialize_schema_ref(v)
|
|
58
|
+
# Enums are a schema_ref but since we pass them around using the underlying value
|
|
59
|
+
# we need to check if they have the serialize method before calling it
|
|
60
|
+
if v.class.method_defined? :serialize
|
|
61
|
+
v.serialize
|
|
62
|
+
else
|
|
63
|
+
v
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
module_function :serialize_schema_ref
|
|
68
|
+
|
|
69
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HookSniff
|
|
4
|
+
# Validation errors have their own schema to provide context for invalid requests eg. mismatched types and out of bounds values. There may be any number of these per 422 UNPROCESSABLE ENTITY error.
|
|
5
|
+
class ValidationError
|
|
6
|
+
# The location as a [`Vec`] of [`String`]s -- often in the form `[\"body\", \"field_name\"]`, `[\"query\", \"field_name\"]`, etc. They may, however, be arbitrarily deep.
|
|
7
|
+
attr_accessor :loc
|
|
8
|
+
|
|
9
|
+
# The message accompanying the validation error item.
|
|
10
|
+
attr_accessor :msg
|
|
11
|
+
|
|
12
|
+
# The type of error, often \"type_error\" or \"value_error\", but sometimes with more context like as \"value_error.number.not_ge\"
|
|
13
|
+
attr_accessor :type
|
|
14
|
+
|
|
15
|
+
def initialize(attributes = {})
|
|
16
|
+
if (!attributes.is_a?(Hash))
|
|
17
|
+
fail(
|
|
18
|
+
ArgumentError,
|
|
19
|
+
"The input argument (attributes) must be a hash in `HookSniff::ValidationError` initialize method"
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
@loc = attributes[:"loc"]
|
|
24
|
+
@msg = attributes[:"msg"]
|
|
25
|
+
@type = attributes[:"type"]
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
data/lib/hooksniff/version.rb
CHANGED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HookSniff
|
|
4
|
+
class Webhook
|
|
5
|
+
|
|
6
|
+
def self.new_using_raw_bytes(secret)
|
|
7
|
+
self.new(secret.pack("C*").force_encoding("UTF-8"))
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def initialize(secret)
|
|
11
|
+
if secret.start_with?(SECRET_PREFIX)
|
|
12
|
+
secret = secret[SECRET_PREFIX.length..-1]
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
@secret = Base64.decode64(secret)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def verify(payload, headers)
|
|
19
|
+
msgId = headers["hooksniff-id"]
|
|
20
|
+
msgSignature = headers["hooksniff-signature"]
|
|
21
|
+
msgTimestamp = headers["hooksniff-timestamp"]
|
|
22
|
+
if !msgSignature || !msgId || !msgTimestamp
|
|
23
|
+
msgId = headers["webhook-id"]
|
|
24
|
+
msgSignature = headers["webhook-signature"]
|
|
25
|
+
msgTimestamp = headers["webhook-timestamp"]
|
|
26
|
+
if !msgSignature || !msgId || !msgTimestamp
|
|
27
|
+
raise WebhookVerificationError, "Missing required headers"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
verify_timestamp(msgTimestamp)
|
|
32
|
+
|
|
33
|
+
_, signature = sign(msgId, msgTimestamp, payload).split(",", 2)
|
|
34
|
+
|
|
35
|
+
passedSignatures = msgSignature.split(" ")
|
|
36
|
+
passedSignatures.each do |versionedSignature|
|
|
37
|
+
version, expectedSignature = versionedSignature.split(",", 2)
|
|
38
|
+
if version != "v1"
|
|
39
|
+
next
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
if ::HookSniff::secure_compare(signature, expectedSignature)
|
|
43
|
+
return nil if payload.empty?
|
|
44
|
+
return JSON.parse(payload, symbolize_names: true)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
raise WebhookVerificationError, "No matching signature found"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def sign(msgId, timestamp, payload)
|
|
52
|
+
begin
|
|
53
|
+
now = Integer(timestamp)
|
|
54
|
+
rescue
|
|
55
|
+
raise WebhookSigningError, "Invalid timestamp"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
toSign = "#{msgId}.#{timestamp}.#{payload}"
|
|
59
|
+
signature = Base64.encode64(OpenSSL::HMAC.digest(OpenSSL::Digest.new("sha256"), @secret, toSign)).strip
|
|
60
|
+
return "v1,#{signature}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
SECRET_PREFIX = "whsec_"
|
|
65
|
+
TOLERANCE = 5 * 60
|
|
66
|
+
|
|
67
|
+
def verify_timestamp(timestampHeader)
|
|
68
|
+
begin
|
|
69
|
+
now = Integer(Time.now)
|
|
70
|
+
timestamp = Integer(timestampHeader)
|
|
71
|
+
rescue
|
|
72
|
+
raise WebhookVerificationError, "Invalid Signature Headers"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
if timestamp < (now - TOLERANCE)
|
|
76
|
+
raise WebhookVerificationError, "Message timestamp too old"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
if timestamp > (now + TOLERANCE)
|
|
80
|
+
raise WebhookVerificationError, "Message timestamp too new"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
data/lib/hooksniff.rb
CHANGED
|
@@ -1,19 +1,78 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
-
require "net/http"
|
|
5
|
-
require "uri"
|
|
6
4
|
require "openssl"
|
|
5
|
+
require "base64"
|
|
6
|
+
require "uri"
|
|
7
|
+
require "logger"
|
|
7
8
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
# API
|
|
10
|
+
require "hooksniff/api/authentication"
|
|
11
|
+
require "hooksniff/api/endpoint"
|
|
12
|
+
require "hooksniff/api/event_type"
|
|
13
|
+
require "hooksniff/api/health"
|
|
14
|
+
require "hooksniff/api/message"
|
|
15
|
+
require "hooksniff/api/message_attempt"
|
|
16
|
+
require "hooksniff/api/statistics"
|
|
12
17
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
18
|
+
# Models
|
|
19
|
+
require "hooksniff/models/aggregate_event_types_out"
|
|
20
|
+
require "hooksniff/models/endpoint_created_event"
|
|
21
|
+
require "hooksniff/models/endpoint_created_event_data"
|
|
22
|
+
require "hooksniff/models/endpoint_deleted_event"
|
|
23
|
+
require "hooksniff/models/endpoint_deleted_event_data"
|
|
24
|
+
require "hooksniff/models/endpoint_disabled_event"
|
|
25
|
+
require "hooksniff/models/endpoint_disabled_event_data"
|
|
26
|
+
require "hooksniff/models/endpoint_enabled_event"
|
|
27
|
+
require "hooksniff/models/endpoint_enabled_event_data"
|
|
28
|
+
require "hooksniff/models/endpoint_headers_in"
|
|
29
|
+
require "hooksniff/models/endpoint_headers_out"
|
|
30
|
+
require "hooksniff/models/endpoint_headers_patch_in"
|
|
31
|
+
require "hooksniff/models/endpoint_in"
|
|
32
|
+
require "hooksniff/models/endpoint_out"
|
|
33
|
+
require "hooksniff/models/endpoint_patch"
|
|
34
|
+
require "hooksniff/models/endpoint_secret_out"
|
|
35
|
+
require "hooksniff/models/endpoint_secret_rotate_in"
|
|
36
|
+
require "hooksniff/models/endpoint_update"
|
|
37
|
+
require "hooksniff/models/endpoint_updated_event"
|
|
38
|
+
require "hooksniff/models/endpoint_updated_event_data"
|
|
39
|
+
require "hooksniff/models/event_in"
|
|
40
|
+
require "hooksniff/models/event_out"
|
|
41
|
+
require "hooksniff/models/event_type_in"
|
|
42
|
+
require "hooksniff/models/event_type_out"
|
|
43
|
+
require "hooksniff/models/event_type_patch"
|
|
44
|
+
require "hooksniff/models/event_type_update"
|
|
45
|
+
require "hooksniff/models/list_response_endpoint_out"
|
|
46
|
+
require "hooksniff/models/list_response_event_type_out"
|
|
47
|
+
require "hooksniff/models/list_response_message_attempt_out"
|
|
48
|
+
require "hooksniff/models/list_response_message_out"
|
|
49
|
+
require "hooksniff/models/message_attempt_exhausted_event"
|
|
50
|
+
require "hooksniff/models/message_attempt_exhausted_event_data"
|
|
51
|
+
require "hooksniff/models/message_attempt_failed_data"
|
|
52
|
+
require "hooksniff/models/message_attempt_failing_event"
|
|
53
|
+
require "hooksniff/models/message_attempt_failing_event_data"
|
|
54
|
+
require "hooksniff/models/message_attempt_log"
|
|
55
|
+
require "hooksniff/models/message_attempt_log_event"
|
|
56
|
+
require "hooksniff/models/message_attempt_out"
|
|
57
|
+
require "hooksniff/models/message_attempt_recovered_event"
|
|
58
|
+
require "hooksniff/models/message_attempt_recovered_event_data"
|
|
59
|
+
require "hooksniff/models/message_attempt_trigger_type"
|
|
60
|
+
require "hooksniff/models/message_in"
|
|
61
|
+
require "hooksniff/models/message_out"
|
|
62
|
+
require "hooksniff/models/message_status"
|
|
63
|
+
require "hooksniff/models/message_status_text"
|
|
64
|
+
require "hooksniff/models/ordering"
|
|
65
|
+
require "hooksniff/models/status_code_class"
|
|
16
66
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
67
|
+
# Core
|
|
68
|
+
require "hooksniff/api_error"
|
|
69
|
+
require "hooksniff/errors"
|
|
70
|
+
require "hooksniff/hooksniff"
|
|
71
|
+
require "hooksniff/util"
|
|
72
|
+
require "hooksniff/version"
|
|
73
|
+
require "hooksniff/webhook"
|
|
74
|
+
require "hooksniff/http_error_out"
|
|
75
|
+
require "hooksniff/http_validation_error"
|
|
76
|
+
require "hooksniff/validation_error"
|
|
77
|
+
require "hooksniff/hooksniff_http_client"
|
|
78
|
+
require "hooksniff/internal"
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
require "hooksniff"
|
|
5
|
+
|
|
6
|
+
class WebhookTest < Minitest::Test
|
|
7
|
+
SECRET = "whsec_dGVzdA=="
|
|
8
|
+
MSG_ID = "msg_test123"
|
|
9
|
+
TIMESTAMP = Time.now.to_i
|
|
10
|
+
PAYLOAD = '{"event":"test"}'
|
|
11
|
+
|
|
12
|
+
def sign(secret, msg_id, timestamp, payload)
|
|
13
|
+
decoded = Base64.decode64(secret.sub("whsec_", ""))
|
|
14
|
+
to_sign = "#{msg_id}.#{timestamp}.#{payload}"
|
|
15
|
+
sig = Base64.strict_encode64(
|
|
16
|
+
OpenSSL::HMAC.digest("SHA256", decoded, to_sign)
|
|
17
|
+
)
|
|
18
|
+
"v1,#{sig}"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def test_verify_valid_signature
|
|
22
|
+
wh = HookSniff::Webhook.new(SECRET)
|
|
23
|
+
sig = sign(SECRET, MSG_ID, TIMESTAMP, PAYLOAD)
|
|
24
|
+
headers = {
|
|
25
|
+
"webhook-id" => MSG_ID,
|
|
26
|
+
"webhook-timestamp" => TIMESTAMP.to_s,
|
|
27
|
+
"webhook-signature" => sig,
|
|
28
|
+
}
|
|
29
|
+
result = wh.verify(PAYLOAD, headers)
|
|
30
|
+
assert_equal({"event" => "test"}, result)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def test_reject_invalid_signature
|
|
34
|
+
wh = HookSniff::Webhook.new(SECRET)
|
|
35
|
+
headers = {
|
|
36
|
+
"webhook-id" => MSG_ID,
|
|
37
|
+
"webhook-timestamp" => TIMESTAMP.to_s,
|
|
38
|
+
"webhook-signature" => "v1,invalid",
|
|
39
|
+
}
|
|
40
|
+
assert_raises(HookSniff::WebhookVerificationError) do
|
|
41
|
+
wh.verify(PAYLOAD, headers)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def test_reject_old_timestamp
|
|
46
|
+
wh = HookSniff::Webhook.new(SECRET)
|
|
47
|
+
old_ts = Time.now.to_i - 600
|
|
48
|
+
sig = sign(SECRET, MSG_ID, old_ts, PAYLOAD)
|
|
49
|
+
headers = {
|
|
50
|
+
"webhook-id" => MSG_ID,
|
|
51
|
+
"webhook-timestamp" => old_ts.to_s,
|
|
52
|
+
"webhook-signature" => sig,
|
|
53
|
+
}
|
|
54
|
+
assert_raises(HookSniff::WebhookVerificationError) do
|
|
55
|
+
wh.verify(PAYLOAD, headers)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def test_svix_branded_headers
|
|
60
|
+
wh = HookSniff::Webhook.new(SECRET)
|
|
61
|
+
sig = sign(SECRET, MSG_ID, TIMESTAMP, PAYLOAD)
|
|
62
|
+
headers = {
|
|
63
|
+
"svix-id" => MSG_ID,
|
|
64
|
+
"svix-timestamp" => TIMESTAMP.to_s,
|
|
65
|
+
"svix-signature" => sig,
|
|
66
|
+
}
|
|
67
|
+
result = wh.verify(PAYLOAD, headers)
|
|
68
|
+
assert_equal({"event" => "test"}, result)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
class ErrorTest < Minitest::Test
|
|
73
|
+
def test_create_error_from_status
|
|
74
|
+
err = HookSniff.create_error_from_status(400)
|
|
75
|
+
assert_instance_of HookSniff::BadRequestError, err
|
|
76
|
+
assert_equal 400, err.code
|
|
77
|
+
|
|
78
|
+
err = HookSniff.create_error_from_status(429)
|
|
79
|
+
assert_instance_of HookSniff::RateLimitError, err
|
|
80
|
+
assert_equal 429, err.code
|
|
81
|
+
|
|
82
|
+
err = HookSniff.create_error_from_status(500)
|
|
83
|
+
assert_instance_of HookSniff::InternalServerError, err
|
|
84
|
+
assert_equal 500, err.code
|
|
85
|
+
end
|
|
86
|
+
end
|