hooks-ruby 0.0.5 → 0.0.7

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 20afc330ce2c974fa7cb50950e261e0ed8bfb01a9e65aa615f1d1fc4aeff3b90
4
- data.tar.gz: 2e561e403eab068f75cce3f41b48ba0642d4fd5f81124d9bea66d563f1472ff4
3
+ metadata.gz: ee95e33a4d74875095dd510623500145b7cd5af0c7727ec0a56232fc5e28260f
4
+ data.tar.gz: 4aa8d3e0484bcd27da118d86ba8b36ea87b9d198d9287fc6d14caac61ae041fe
5
5
  SHA512:
6
- metadata.gz: 1152f3eef93d6c1d872b13dc59154584229f3bfed17a46c2cc748c0f8d1bd9847441dbe96ca2d084bdb6ed6f30077bcf0b49515c8e46bf668c355c8bdb5d52b0
7
- data.tar.gz: '038523ad1ed72fc0f27fad4172a15b316c02d52b95936951a3f9f9efbdc2034dc074847665ee43524d7af93f6432ddd91c0ac97b2a0dbafa0f10deea8238fd67'
6
+ metadata.gz: 10192508ea9438ef5bdd8c5c9b4c522cec0ec5b8e76e8584d21756e87b5560579172141ea11f02b5920c33fcc4d10aa8d9ad9d2d86dabe36585a859be74321e2
7
+ data.tar.gz: 33a76e31f51eaad74c9a91db2b1e2427dfeae29614acfb31e21cb7c7ac4c8d5f997a4f249f79e2f08e176d2ab8fa1eb0a8bfcbb429218d5a8f7ebf9e3179482c
data/README.md CHANGED
@@ -153,17 +153,22 @@ This example will assume the following directory structure:
153
153
 
154
154
  ```text
155
155
  ├── config/
156
- │ ├── hooks.yml # global hooks config
157
- │ ├── puma.rb # puma config
156
+ │ ├── hooks.yml # global hooks config
157
+ │ ├── puma.rb # puma config
158
158
  │ └── endpoints/
159
159
  │ ├── hello.yml
160
160
  │ └── goodbye.yml
161
161
  └── plugins/
162
- ├── handlers/ # custom handler plugins
162
+ ├── handlers/ # custom handler plugins
163
163
  │ ├── hello_handler.rb
164
164
  │ └── goodbye_handler.rb
165
+ ├── lifecycle/ # custom lifecycle plugins (optional)
166
+ │ └── my_lifecycle_plugin.rb # custom lifecycle plugin (optional)
167
+ ├── instruments/ # custom instrument plugins (optional)
168
+ │ ├── stats.rb # a custom stats instrument plugin (optional)
169
+ │ └── failbot.rb # a custom error notifier instrument plugin (optional)
165
170
  └── auth/
166
- └── goodbye_auth.rb # custom auth plugin (optional)
171
+ └── goodbye_auth.rb # custom auth plugin (optional)
167
172
  ```
168
173
 
169
174
  Let's go through each step in detail.
@@ -174,8 +179,10 @@ First, create a `hooks.yml` file in the `config` directory. This file will defin
174
179
 
175
180
  ```yaml
176
181
  # file: config/hooks.yml
177
- handler_plugin_dir: ./plugins/handlers
178
- auth_plugin_dir: ./plugins/auth
182
+ handler_plugin_dir: ./plugins/handlers # Directory for handler plugins
183
+ auth_plugin_dir: ./plugins/auth # Directory for authentication plugins (optional)
184
+ lifecycle_plugin_dir: ./plugins/lifecycle # Directory for lifecycle plugins (optional)
185
+ instruments_plugin_dir: ./plugins/instruments # Directory for instrument plugins (optional)
179
186
 
180
187
  # Available endpoints
181
188
  # Each endpoint configuration file should be placed in the endpoints directory
@@ -213,6 +220,10 @@ auth:
213
220
  type: Goodbye # This is a custom authentication plugin you would define in the plugins/auth
214
221
  secret_env_key: GOODBYE_API_KEY # the name of the environment variable containing the secret
215
222
  header: Authorization
223
+
224
+ # Optional additional options for the endpoint (can be anything you want)
225
+ opts:
226
+ foo: bar
216
227
  ```
217
228
 
218
229
  #### 3. Implement your handler plugins
@@ -251,6 +262,8 @@ class GoodbyeHandler < Hooks::Plugins::Handlers::Base
251
262
  end
252
263
  ```
253
264
 
265
+ See the [Handler Plugins](docs/handler_plugins.md) documentation for more information on how to create your own custom handler plugins and what the values of `payload`, `headers`, and `config` are when the `call` method is invoked.
266
+
254
267
  #### 4. Implement authentication plugins (optional)
255
268
 
256
269
  If you want to secure your webhook endpoints, you can create custom authentication plugins in the `plugins/auth` directory. Here is an example of a simple authentication plugin for the `/goodbye` endpoint:
@@ -268,7 +281,7 @@ module Hooks
268
281
  secret = fetch_secret(config, secret_env_key_name: :secret_env_key)
269
282
 
270
283
  # check if the Authorization header matches the secret
271
- auth_header = headers[config[:header]]
284
+ auth_header = headers[config[:auth][:header]]
272
285
  return false unless auth_header
273
286
 
274
287
  # compare the Authorization header with the secret
@@ -280,6 +293,8 @@ module Hooks
280
293
  end
281
294
  ```
282
295
 
296
+ To learn more about how you can create your own custom authentication plugins, see the [Auth Plugins](docs/auth_plugins.md) documentation.
297
+
283
298
  #### Summary
284
299
 
285
300
  What these steps have done is set up a Hooks server that listens for incoming webhook requests at `/webhooks/hello` and `/webhooks/goodbye`. The `/hello` endpoint will respond with a success message without any authentication, while the `/goodbye` endpoint will require a valid `Authorization` header that matches the secret defined in the environment variable `GOODBYE_API_KEY`. Before the `/goodbye` endpoint enters the defined handler, it will first check the authentication plugin to ensure the request is valid.
data/lib/hooks/app/api.rb CHANGED
@@ -102,14 +102,13 @@ module Hooks
102
102
  validate_auth!(raw_body, headers, endpoint_config, config)
103
103
  end
104
104
 
105
- payload = parse_payload(raw_body, headers, symbolize: config[:symbolize_payload])
105
+ payload = parse_payload(raw_body, headers, symbolize: false)
106
106
  handler = load_handler(handler_class_name)
107
- normalized_headers = config[:normalize_headers] ? Hooks::Utils::Normalize.headers(headers) : headers
108
- symbolized_headers = config[:symbolize_headers] ? Hooks::Utils::Normalize.symbolize_headers(normalized_headers) : normalized_headers
107
+ processed_headers = config[:normalize_headers] ? Hooks::Utils::Normalize.headers(headers) : headers
109
108
 
110
109
  response = handler.call(
111
110
  payload:,
112
- headers: symbolized_headers,
111
+ headers: processed_headers,
113
112
  config: endpoint_config
114
113
  )
115
114
 
@@ -44,9 +44,9 @@ module Hooks
44
44
  #
45
45
  # @param raw_body [String] The raw request body
46
46
  # @param headers [Hash] The request headers
47
- # @param symbolize [Boolean] Whether to symbolize keys in parsed JSON (default: true)
48
- # @return [Hash, String] Parsed JSON as Hash (optionally symbolized), or raw body if not JSON
49
- def parse_payload(raw_body, headers, symbolize: true)
47
+ # @param symbolize [Boolean] Whether to symbolize keys in parsed JSON (default: false)
48
+ # @return [Hash, String] Parsed JSON as Hash with string keys, or raw body if not JSON
49
+ def parse_payload(raw_body, headers, symbolize: false)
50
50
  # Optimized content type check - check most common header first
51
51
  content_type = headers["Content-Type"] || headers["CONTENT_TYPE"] || headers["content-type"] || headers["HTTP_CONTENT_TYPE"]
52
52
 
@@ -55,6 +55,7 @@ module Hooks
55
55
  begin
56
56
  # Security: Limit JSON parsing depth and complexity to prevent JSON bombs
57
57
  parsed_payload = safe_json_parse(raw_body)
58
+ # Note: symbolize parameter is kept for backward compatibility but defaults to false
58
59
  parsed_payload = parsed_payload.transform_keys(&:to_sym) if symbolize && parsed_payload.is_a?(Hash)
59
60
  return parsed_payload
60
61
  rescue JSON::ParserError, ArgumentError => e
@@ -20,9 +20,7 @@ module Hooks
20
20
  production: true,
21
21
  endpoints_dir: "./config/endpoints",
22
22
  use_catchall_route: false,
23
- symbolize_payload: true,
24
- normalize_headers: true,
25
- symbolize_headers: true
23
+ normalize_headers: true
26
24
  }.freeze
27
25
 
28
26
  SILENCE_CONFIG_LOADER_MESSAGES = ENV.fetch(
@@ -142,9 +140,7 @@ module Hooks
142
140
  "HOOKS_ENVIRONMENT" => :environment,
143
141
  "HOOKS_ENDPOINTS_DIR" => :endpoints_dir,
144
142
  "HOOKS_USE_CATCHALL_ROUTE" => :use_catchall_route,
145
- "HOOKS_SYMBOLIZE_PAYLOAD" => :symbolize_payload,
146
143
  "HOOKS_NORMALIZE_HEADERS" => :normalize_headers,
147
- "HOOKS_SYMBOLIZE_HEADERS" => :symbolize_headers,
148
144
  "HOOKS_SOME_STRING_VAR" => :some_string_var # Added for test
149
145
  }
150
146
 
@@ -156,7 +152,7 @@ module Hooks
156
152
  case config_key
157
153
  when :request_limit, :request_timeout
158
154
  env_config[config_key] = value.to_i
159
- when :use_catchall_route, :symbolize_payload, :normalize_headers, :symbolize_headers
155
+ when :use_catchall_route, :normalize_headers
160
156
  # Convert string to boolean
161
157
  env_config[config_key] = %w[true 1 yes on].include?(value.downcase)
162
158
  else
@@ -26,7 +26,6 @@ module Hooks
26
26
  optional(:environment).filled(:string, included_in?: %w[development production])
27
27
  optional(:endpoints_dir).filled(:string)
28
28
  optional(:use_catchall_route).filled(:bool)
29
- optional(:symbolize_payload).filled(:bool)
30
29
  optional(:normalize_headers).filled(:bool)
31
30
  end
32
31
 
@@ -43,11 +43,30 @@ module Hooks
43
43
  secret = ENV[secret_env_key]
44
44
 
45
45
  if secret.nil? || !secret.is_a?(String) || secret.strip.empty?
46
- raise StandardError, "authentication configuration incomplete: missing secret value bound to #{secret_env_key_name}"
46
+ raise StandardError, "authentication configuration incomplete: missing secret value for environment variable"
47
47
  end
48
48
 
49
49
  return secret.strip
50
50
  end
51
+
52
+ # Find a header value by name with case-insensitive matching
53
+ #
54
+ # @param headers [Hash] HTTP headers from the request
55
+ # @param header_name [String] Name of the header to find
56
+ # @return [String, nil] The header value if found, nil otherwise
57
+ # @note This method performs case-insensitive header matching
58
+ def self.find_header_value(headers, header_name)
59
+ return nil unless headers.respond_to?(:each)
60
+ return nil if header_name.nil? || header_name.strip.empty?
61
+
62
+ target_header = header_name.downcase
63
+ headers.each do |key, value|
64
+ if key.to_s.downcase == target_header
65
+ return value.to_s
66
+ end
67
+ end
68
+ nil
69
+ end
51
70
  end
52
71
  end
53
72
  end
@@ -92,26 +92,32 @@ module Hooks
92
92
  validator_config = build_config(config)
93
93
 
94
94
  # Security: Check raw headers BEFORE normalization to detect tampering
95
- return false unless headers.respond_to?(:each)
95
+ unless headers.respond_to?(:each)
96
+ log.warn("Auth::HMAC validation failed: Invalid headers object")
97
+ return false
98
+ end
96
99
 
97
100
  signature_header = validator_config[:header]
98
101
 
99
- # Find the signature header with case-insensitive matching but preserve original value
100
- raw_signature = nil
101
- headers.each do |key, value|
102
- if key.to_s.downcase == signature_header.downcase
103
- raw_signature = value.to_s
104
- break
105
- end
106
- end
102
+ # Find the signature header with case-insensitive matching
103
+ raw_signature = find_header_value(headers, signature_header)
107
104
 
108
- return false if raw_signature.nil? || raw_signature.empty?
105
+ if raw_signature.nil? || raw_signature.empty?
106
+ log.warn("Auth::HMAC validation failed: Missing or empty signature header '#{signature_header}'")
107
+ return false
108
+ end
109
109
 
110
110
  # Security: Reject signatures with leading/trailing whitespace
111
- return false if raw_signature != raw_signature.strip
111
+ if raw_signature != raw_signature.strip
112
+ log.warn("Auth::HMAC validation failed: Signature contains leading/trailing whitespace")
113
+ return false
114
+ end
112
115
 
113
116
  # Security: Reject signatures containing null bytes or other control characters
114
- return false if raw_signature.match?(/[\u0000-\u001f\u007f-\u009f]/)
117
+ if raw_signature.match?(/[\u0000-\u001f\u007f-\u009f]/)
118
+ log.warn("Auth::HMAC validation failed: Signature contains control characters")
119
+ return false
120
+ end
115
121
 
116
122
  # Now we can safely normalize headers for the rest of the validation
117
123
  normalized_headers = normalize_headers(headers)
@@ -134,7 +140,13 @@ module Hooks
134
140
  )
135
141
 
136
142
  # Use secure comparison to prevent timing attacks
137
- Rack::Utils.secure_compare(computed_signature, provided_signature)
143
+ result = Rack::Utils.secure_compare(computed_signature, provided_signature)
144
+ if result
145
+ log.debug("Auth::HMAC validation successful for header '#{signature_header}'")
146
+ else
147
+ log.warn("Auth::HMAC validation failed: Signature mismatch")
148
+ end
149
+ result
138
150
  rescue StandardError => e
139
151
  log.error("Auth::HMAC validation failed: #{e.message}")
140
152
  false
@@ -62,37 +62,56 @@ module Hooks
62
62
  validator_config = build_config(config)
63
63
 
64
64
  # Security: Check raw headers BEFORE normalization to detect tampering
65
- return false unless headers.respond_to?(:each)
65
+ unless headers.respond_to?(:each)
66
+ log.warn("Auth::SharedSecret validation failed: Invalid headers object")
67
+ return false
68
+ end
66
69
 
67
70
  secret_header = validator_config[:header]
68
71
 
69
- # Find the secret header with case-insensitive matching but preserve original value
70
- raw_secret = nil
71
- headers.each do |key, value|
72
- if key.to_s.downcase == secret_header.downcase
73
- raw_secret = value.to_s
74
- break
75
- end
76
- end
72
+ # Find the secret header with case-insensitive matching
73
+ raw_secret = find_header_value(headers, secret_header)
77
74
 
78
- return false if raw_secret.nil? || raw_secret.empty?
75
+ if raw_secret.nil? || raw_secret.empty?
76
+ log.warn("Auth::SharedSecret validation failed: Missing or empty secret header '#{secret_header}'")
77
+ return false
78
+ end
79
79
 
80
80
  stripped_secret = raw_secret.strip
81
81
 
82
82
  # Security: Reject secrets with leading/trailing whitespace
83
- return false if raw_secret != stripped_secret
83
+ if raw_secret != stripped_secret
84
+ log.warn("Auth::SharedSecret validation failed: Secret contains leading/trailing whitespace")
85
+ return false
86
+ end
84
87
 
85
88
  # Security: Reject secrets containing null bytes or other control characters
86
- return false if raw_secret.match?(/[\u0000-\u001f\u007f-\u009f]/)
89
+ if raw_secret.match?(/[\u0000-\u001f\u007f-\u009f]/)
90
+ log.warn("Auth::SharedSecret validation failed: Secret contains control characters")
91
+ return false
92
+ end
87
93
 
88
94
  # Use secure comparison to prevent timing attacks
89
- Rack::Utils.secure_compare(secret, stripped_secret)
90
- rescue StandardError => _e
95
+ result = Rack::Utils.secure_compare(secret, stripped_secret)
96
+ if result
97
+ log.debug("Auth::SharedSecret validation successful for header '#{secret_header}'")
98
+ else
99
+ log.warn("Auth::SharedSecret validation failed: Signature mismatch")
100
+ end
101
+ result
102
+ rescue StandardError => e
103
+ log.error("Auth::SharedSecret validation failed: #{e.message}")
91
104
  false
92
105
  end
93
106
 
94
107
  private
95
108
 
109
+ # Short logger accessor for auth module
110
+ # @return [Hooks::Log] Logger instance
111
+ def self.log
112
+ Hooks::Log.instance
113
+ end
114
+
96
115
  # Build final configuration by merging defaults with provided config
97
116
  #
98
117
  # Combines default configuration values with user-provided settings,
@@ -15,7 +15,7 @@ module Hooks
15
15
  # Process a webhook request
16
16
  #
17
17
  # @param payload [Hash, String] Parsed request body (JSON Hash) or raw string
18
- # @param headers [Hash] HTTP headers (symbolized keys by default)
18
+ # @param headers [Hash] HTTP headers (string keys, optionally normalized - default is normalized)
19
19
  # @param config [Hash] Merged endpoint configuration including opts section (symbolized keys)
20
20
  # @return [Hash, String, nil] Response body (will be auto-converted to JSON)
21
21
  # @raise [NotImplementedError] if not implemented by subclass
data/lib/hooks/version.rb CHANGED
@@ -4,5 +4,5 @@
4
4
  module Hooks
5
5
  # Current version of the Hooks webhook framework
6
6
  # @return [String] The version string following semantic versioning
7
- VERSION = "0.0.5".freeze
7
+ VERSION = "0.0.7".freeze
8
8
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hooks-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.5
4
+ version: 0.0.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - github