webhook_system 1.0.4 → 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: ab92b0226e4fcb3e5fd0b51f7c263671a26a872d
4
- data.tar.gz: ebec116d1079659ad550232957c62c365ac30784
3
+ metadata.gz: d120e25bddd3b90b7f79a51f5ee17aaf9e161336
4
+ data.tar.gz: e872d6ca6dad0879a5c71b9ca0ace3c129bc33c1
5
5
  SHA512:
6
- metadata.gz: 7d78db773737cac39123362eb472dbdbb53f2e7c8b5f157836532f023fe48d7ddc454be64f74fe9bad27c21d86afcda9dae1f9f8d5f8436b808d259a8a4faaf3
7
- data.tar.gz: c5aa162d977732bfe5eaa91cc185643cc1db22987fbcc6afa2492e0c1a437a402acfd1b7e118f0832bf40305da911e64f5de4125cfd041c8fbe762c88bcdaa24
6
+ metadata.gz: 793b9a205d0244f79aad3fad61ffd2aadbde2f65b16545acebb7c9192859a85d1f6aec764371905baf373eeaa8950e1cd31c8906f9a47a4a27370b550e672733
7
+ data.tar.gz: fb7a2493ef2d3a666162c9eb3b4d1ce1b9cc8b1b58261869ec11a672514eb83b06458a874701dd602bdedf696df3cecdb87e5e2f510038884b578faddffaff3b
data/.codeclimate.yml CHANGED
@@ -1,4 +1,9 @@
1
1
  engines:
2
+ duplication:
3
+ enabled: true
4
+ config:
5
+ languages:
6
+ - ruby
2
7
  rubocop:
3
8
  enabled: true
4
9
  ratings:
data/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Change Log
2
2
 
3
+ ## [v1.0.4](https://github.com/payrollhero/webhook_system/tree/v1.0.4) (2016-02-19)
4
+ [Full Changelog](https://github.com/payrollhero/webhook_system/compare/v1.0.3...v1.0.4)
5
+
6
+ - Log any exception which occurs while we submit data to the webhook url. [\#8](https://github.com/payrollhero/webhook_system/pull/8) ([mykola-kyryk](https://github.com/mykola-kyryk))
7
+
3
8
  ## [v1.0.3](https://github.com/payrollhero/webhook_system/tree/v1.0.3) (2016-02-18)
4
9
  [Full Changelog](https://github.com/payrollhero/webhook_system/compare/v1.0.2...v1.0.3)
5
10
 
data/README.md CHANGED
@@ -29,37 +29,39 @@ tables first:
29
29
  ```ruby
30
30
  create_table :webhook_subscriptions do |t|
31
31
  t.string :url, null: false
32
- t.boolean :active, null: false
32
+ t.boolean :active, null: false, index: true
33
+ t.boolean :encrypt, null: false, default: false
33
34
  t.text :secret
34
-
35
- t.index :active
36
35
  end
37
36
 
38
37
  create_table :webhook_subscription_topics do |t|
39
- t.string :name, null: false
40
- t.belongs_to :subscription, null: false
41
-
42
- t.index :subscription_id
43
- t.index :name
38
+ t.string :name, null: false, index: true
39
+ t.belongs_to :subscription, null: false, index: true
44
40
  end
45
41
 
46
42
  create_table :webhook_event_logs do |t|
47
- t.belongs_to :subscription, null: false
43
+ t.belongs_to :subscription, null: false, index: true
48
44
 
49
- t.string :event_name, null: false
50
- t.string :event_id, null: false
51
- t.integer :status, null: false
45
+ t.string :event_name, null: false, index: true
46
+ t.string :event_id, null: false, index: true
47
+ t.integer :status, null: false, index: true
52
48
 
53
49
  t.text :request, limit: 64_000, null: false
54
50
  t.text :response, limit: 64_000, null: false
55
51
 
56
- t.datetime :created_at, null: false
52
+ t.datetime :created_at, null: false, index: true
53
+ end
54
+ ```
57
55
 
58
- t.index :created_at
59
- t.index :event_name
60
- t.index :status
61
- t.index :subscription_id
62
- t.index :event_id
56
+ ### Migrating from version 1.x
57
+
58
+ The main new change is the addition of a new 'encrypt' column on subscriptions
59
+
60
+ Add this migration to get this added (and retain original behavior)
61
+
62
+ ```ruby
63
+ def change
64
+ add_column :webhook_subscriptions, :encrypt, :boolean, default: true, null: false, after: :active
63
65
  end
64
66
  ```
65
67
 
@@ -207,9 +209,22 @@ WebhookSystem.dispatch(event_object)
207
209
  This is meant to be fairly fire and forget. Internally this will create an ActiveJob for each subscription
208
210
  interested in the event.
209
211
 
212
+ # Payload Format
213
+
214
+ Payloads can either be plain json or encrypted. On top of that, they're also signed. The format for the signature
215
+ follows GitHub's own format: [https://developer.github.com/webhooks/securing/](https://developer.github.com/webhooks/securing/).
216
+ The subscription's secret is used to create the signature.
217
+
218
+ The payload can be encrypted based on the `encrypt` boolean column of a subscription.
219
+
220
+ ## Payload Verification
221
+
222
+ This library can be used as a helper to decode and verify the payloads as well. The same usage as the Decryption below
223
+ will also verify the signature if present in the headers passed.
224
+
210
225
  ## Payload Encryption
211
226
 
212
- The payload is encrypted using AES-256. Each subscription is meant to have the recipient's shared secret on it.
227
+ The payload can be encrypted using AES-256. Each subscription is meant to have the recipient's shared secret on it.
213
228
  This secret is then used to encrypt the payload, so the other side needs that same secret again to open it.
214
229
 
215
230
  The payload then will be a json post body, with the Base64 encoded payload inside it.
@@ -221,7 +236,7 @@ There is a utility function available to decode the entire POST body of the webh
221
236
  Example use would be:
222
237
 
223
238
  ```ruby
224
- payload = WebhookSystem::Encoder.decode(secret_string, request.body)
239
+ payload = WebhookSystem::Encoder.decode(secret_string, request.body, request.headers)
225
240
  ```
226
241
 
227
242
  You will need your webhook secret, and you get back a Hash of the event's data.
data/Rakefile CHANGED
@@ -8,7 +8,6 @@ RSpec::Core::RakeTask.new
8
8
 
9
9
  task :test do
10
10
  sh "rspec"
11
- sh "reek"
12
11
  sh "rubocop"
13
12
  end
14
13
 
@@ -22,7 +21,6 @@ task :styleguide do
22
21
  files = %w{
23
22
  .rubocop.hound.yml
24
23
  .rubocop.yml
25
- .reek
26
24
  .codeclimate.yml
27
25
  }
28
26
  files.each do |file|
@@ -8,60 +8,115 @@ module WebhookSystem
8
8
  # @param [String] secret_string some secret string
9
9
  # @param [Object#to_json] payload Any object that responds to to_json
10
10
  # @return [String] The encoded string payload (its a JSON string)
11
- def self.encode(secret_string, payload)
12
- cipher = OpenSSL::Cipher::AES256.new(:CBC)
13
- cipher.encrypt
14
- iv = cipher.random_iv
15
- cipher.key = key_from_secret(iv, secret_string)
16
- encoded = cipher.update(payload.to_json) + cipher.final
17
- Payload.encode(encoded, iv)
11
+ def self.encode(secret_string, payload, format:)
12
+ response_hash = Payload.encode(payload, secret: secret_string, format: format)
13
+ payload_string = JSON.generate(response_hash)
14
+ signature = hub_signature(payload_string, secret_string)
15
+ [payload_string, { 'X-Hub-Signature' => signature, 'Content-Type' => content_type_for_format(format) }]
18
16
  end
19
17
 
20
18
  # Given a secret string, and an encrypted payload, unwrap it, bas64 decode it
21
19
  # decrypt it, and JSON decode it
22
20
  #
23
21
  # @param [String] secret_string some secret string
24
- # @param [String] payload String as returned from #encode
22
+ # @param [String] payload_string String as returned from #encode
25
23
  # @return [Object] return the JSON decode of the encrypted payload
26
- def self.decode(secret_string, payload)
27
- encoded, iv = Payload.decode(payload)
28
- cipher = OpenSSL::Cipher::AES256.new(:CBC)
29
- cipher.decrypt
30
- cipher.iv = iv
31
- cipher.key = key_from_secret(iv, secret_string)
32
- decoded = cipher.update(encoded) + cipher.final
33
- JSON.load(decoded)
34
- rescue OpenSSL::Cipher::CipherError
35
- raise DecodingError, 'Decoding Failed, probably mismatched secret'
24
+ def self.decode(secret_string, payload_string, headers = {})
25
+ signature = headers['X-Hub-Signature']
26
+ format = format_for_content_type(headers.fetch('Content-Type'))
27
+
28
+ payload_signature = hub_signature(payload_string, secret_string)
29
+ if signature && signature != payload_signature
30
+ raise DecodingError, 'signature mismatch'
31
+ end
32
+
33
+ Payload.decode(payload_string, secret: secret_string, format: format)
36
34
  end
37
35
 
38
36
  class << self
39
37
  private
40
38
 
41
- def key_from_secret(iv, secret_string)
42
- OpenSSL::PKCS5.pbkdf2_hmac(secret_string, iv, 100_000, 256 / 8, 'SHA256')
39
+ def content_type_format_map
40
+ {
41
+ 'base64+aes256' => 'application/json; base64+aes256',
42
+ 'json' => 'application/json'
43
+ }
44
+ end
45
+
46
+ def format_for_content_type(content_type)
47
+ content_type_format_map.invert.fetch(content_type)
48
+ end
49
+
50
+ def content_type_for_format(format)
51
+ content_type_format_map.fetch(format)
52
+ end
53
+
54
+ def hub_signature(payload_string, secret)
55
+ 'sha1=' + OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha1'), secret, payload_string)
43
56
  end
44
57
  end
45
58
  end
46
59
 
47
- # private class to just wrap the outer wrapping of the response format
48
- # not exposed to the outside
49
- # :nodoc:
50
60
  module Payload
51
- def self.encode(raw_encrypted_data, iv)
52
- JSON.pretty_generate(
53
- 'format' => 'base64+aes256',
54
- 'payload' => Base64.encode64(raw_encrypted_data),
55
- 'iv' => Base64.encode64(iv)
56
- )
57
- end
61
+ class << self
62
+ def encode(payload, secret:, format:)
63
+ case format
64
+ when 'base64+aes256'
65
+ encode_aes(payload, secret)
66
+ when 'json'
67
+ payload
68
+ else
69
+ raise ArgumentError, "don't know how to handle: #{payload['format']} payload"
70
+ end
71
+ end
58
72
 
59
- def self.decode(payload_string)
60
- payload = JSON.load(payload_string)
61
- unless payload['format'] == 'base64+aes256'
62
- raise ArgumentError, 'only know how to handle base64+aes256 payloads'
73
+ def decode(response_body, secret:, format:)
74
+ payload = JSON.load(response_body)
75
+
76
+ case format
77
+ when 'base64+aes256'
78
+ decode_aes(payload, secret)
79
+ when 'json'
80
+ payload
81
+ else
82
+ raise ArgumentError, "don't know how to handle: #{payload['format']} payload"
83
+ end
84
+ end
85
+
86
+ private
87
+
88
+ def encode_aes(payload, secret)
89
+ cipher = OpenSSL::Cipher::AES256.new(:CBC)
90
+ cipher.encrypt
91
+ iv = cipher.random_iv
92
+ cipher.key = key_from_secret(iv, secret)
93
+ encoded = cipher.update(payload.to_json) + cipher.final
94
+
95
+ {
96
+ format: 'base64+aes256',
97
+ payload: Base64.encode64(encoded),
98
+ iv: Base64.encode64(iv),
99
+ }
100
+ end
101
+
102
+ def decode_aes(payload, secret)
103
+ encoded = Base64.decode64(payload['payload'])
104
+ iv = Base64.decode64(payload['iv'])
105
+
106
+ cipher = OpenSSL::Cipher::AES256.new(:CBC)
107
+ cipher.decrypt
108
+ cipher.iv = iv
109
+ cipher.key = key_from_secret(iv, secret)
110
+ decoded = cipher.update(encoded) + cipher.final
111
+
112
+ JSON.load(decoded)
113
+ rescue OpenSSL::Cipher::CipherError
114
+ raise DecodingError, 'Decoding Failed, probably mismatched secret'
115
+ end
116
+
117
+ def key_from_secret(iv, secret_string)
118
+ OpenSSL::PKCS5.pbkdf2_hmac(secret_string, iv, 100_000, 256 / 8, 'SHA256')
63
119
  end
64
- [Base64.decode64(payload['payload']), Base64.decode64(payload['iv'])]
65
120
  end
66
121
  end
67
122
  end
@@ -41,7 +41,7 @@ module WebhookSystem
41
41
  response =
42
42
  begin
43
43
  client.builder.build_response(client, request)
44
- rescue Exception => exception # we do want to catch all exceptions
44
+ rescue RuntimeError => exception
45
45
  ErrorResponse.new(exception)
46
46
  end
47
47
 
@@ -57,14 +57,18 @@ module WebhookSystem
57
57
  end
58
58
 
59
59
  def self.build_request(client, subscription, event)
60
- payload = Encoder.encode(subscription.secret, event)
60
+ payload, headers = Encoder.encode(subscription.secret, event, format: format_for_subscription(subscription))
61
61
  client.build_request(:post) do |req|
62
62
  req.url subscription.url
63
- req.headers['Content-Type'] = 'application/json; base64+aes256'
63
+ req.headers.merge!(headers)
64
64
  req.body = payload.to_s
65
65
  end
66
66
  end
67
67
 
68
+ def self.format_for_subscription(subscription)
69
+ subscription.encrypt ? 'base64+aes256' : 'json'
70
+ end
71
+
68
72
  def self.log_response(subscription, event, request, response)
69
73
  event_log = EventLog.construct(subscription, event, request, response)
70
74
 
@@ -1,3 +1,3 @@
1
1
  module WebhookSystem
2
- VERSION = '1.0.4'
2
+ VERSION = '2.0.0'
3
3
  end
@@ -18,8 +18,8 @@ Gem::Specification.new do |gem|
18
18
  gem.executables = gem.files.grep(%r{^bin/}).map { |f| File.basename(f) }
19
19
  gem.require_paths = ['lib']
20
20
 
21
- gem.add_runtime_dependency 'activesupport', '> 3.2'
22
- gem.add_runtime_dependency 'activerecord', '> 3.2'
21
+ gem.add_runtime_dependency 'activesupport', '> 3.2', '< 5.0' # have to drop support for ruby 2.1 if we enable 5.0
22
+ gem.add_runtime_dependency 'activerecord', '> 3.2', '< 5.0' # have to drop support for ruby 2.1 if we enable 5.0
23
23
  gem.add_runtime_dependency 'activejob'
24
24
  gem.add_runtime_dependency 'faraday', '~> 0.9'
25
25
  gem.add_runtime_dependency 'faraday-encoding', '>= 0.0.2', '< 1.0'
@@ -37,6 +37,5 @@ Gem::Specification.new do |gem|
37
37
  gem.add_development_dependency 'webmock'
38
38
 
39
39
  # static analysis gems
40
- gem.add_development_dependency 'rubocop', '~> 0.37.1'
41
- gem.add_development_dependency 'reek', '~> 3.7'
40
+ gem.add_development_dependency 'rubocop', '~> 0.41.2'
42
41
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: webhook_system
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.4
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Piotr Banasik
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: exe
11
11
  cert_chain: []
12
- date: 2016-02-19 00:00:00.000000000 Z
12
+ date: 2016-07-19 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: activesupport
@@ -18,6 +18,9 @@ dependencies:
18
18
  - - ">"
19
19
  - !ruby/object:Gem::Version
20
20
  version: '3.2'
21
+ - - "<"
22
+ - !ruby/object:Gem::Version
23
+ version: '5.0'
21
24
  type: :runtime
22
25
  prerelease: false
23
26
  version_requirements: !ruby/object:Gem::Requirement
@@ -25,6 +28,9 @@ dependencies:
25
28
  - - ">"
26
29
  - !ruby/object:Gem::Version
27
30
  version: '3.2'
31
+ - - "<"
32
+ - !ruby/object:Gem::Version
33
+ version: '5.0'
28
34
  - !ruby/object:Gem::Dependency
29
35
  name: activerecord
30
36
  requirement: !ruby/object:Gem::Requirement
@@ -32,6 +38,9 @@ dependencies:
32
38
  - - ">"
33
39
  - !ruby/object:Gem::Version
34
40
  version: '3.2'
41
+ - - "<"
42
+ - !ruby/object:Gem::Version
43
+ version: '5.0'
35
44
  type: :runtime
36
45
  prerelease: false
37
46
  version_requirements: !ruby/object:Gem::Requirement
@@ -39,6 +48,9 @@ dependencies:
39
48
  - - ">"
40
49
  - !ruby/object:Gem::Version
41
50
  version: '3.2'
51
+ - - "<"
52
+ - !ruby/object:Gem::Version
53
+ version: '5.0'
42
54
  - !ruby/object:Gem::Dependency
43
55
  name: activejob
44
56
  requirement: !ruby/object:Gem::Requirement
@@ -247,28 +259,14 @@ dependencies:
247
259
  requirements:
248
260
  - - "~>"
249
261
  - !ruby/object:Gem::Version
250
- version: 0.37.1
251
- type: :development
252
- prerelease: false
253
- version_requirements: !ruby/object:Gem::Requirement
254
- requirements:
255
- - - "~>"
256
- - !ruby/object:Gem::Version
257
- version: 0.37.1
258
- - !ruby/object:Gem::Dependency
259
- name: reek
260
- requirement: !ruby/object:Gem::Requirement
261
- requirements:
262
- - - "~>"
263
- - !ruby/object:Gem::Version
264
- version: '3.7'
262
+ version: 0.41.2
265
263
  type: :development
266
264
  prerelease: false
267
265
  version_requirements: !ruby/object:Gem::Requirement
268
266
  requirements:
269
267
  - - "~>"
270
268
  - !ruby/object:Gem::Version
271
- version: '3.7'
269
+ version: 0.41.2
272
270
  description: A pluggable webhook subscription system
273
271
  email: piotr@payrollhero.com
274
272
  executables: []
@@ -277,8 +275,6 @@ extra_rdoc_files: []
277
275
  files:
278
276
  - ".codeclimate.yml"
279
277
  - ".gitignore"
280
- - ".hound.yml"
281
- - ".reek"
282
278
  - ".rubocop.hound.yml"
283
279
  - ".rubocop.yml"
284
280
  - ".travis.yml"
@@ -318,7 +314,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
318
314
  version: '0'
319
315
  requirements: []
320
316
  rubyforge_project:
321
- rubygems_version: 2.2.5
317
+ rubygems_version: 2.5.1
322
318
  signing_key:
323
319
  specification_version: 4
324
320
  summary: Webhook system
data/.hound.yml DELETED
@@ -1,2 +0,0 @@
1
- ruby:
2
- config_file: .rubocop.yml
data/.reek DELETED
@@ -1,13 +0,0 @@
1
- DuplicateMethodCall:
2
- max_calls: 2
3
- TooManyStatements:
4
- max_statements: 8
5
- NestedIterators:
6
- max_allowed_nesting: 3
7
- DataClump:
8
- max_copies: 4
9
- LongParameterList:
10
- max_params: 8
11
-
12
- exclude_paths:
13
- - spec/support