hooks-ruby 0.0.6 → 0.1.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/README.md +24 -9
- data/lib/hooks/core/config_loader.rb +1 -1
- data/lib/hooks/plugins/auth/base.rb +20 -1
- data/lib/hooks/plugins/auth/hmac.rb +25 -13
- data/lib/hooks/plugins/auth/shared_secret.rb +33 -14
- data/lib/hooks/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 2a1dfcc57a6bae5c2e5bd01f7e6165218220f04258ac8fab3ab256b685454bb6
|
4
|
+
data.tar.gz: 724dbe5463d5bf223ef9652e566b448f38a4270d0131446e302236685ba43710
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: d21c5cce4267f7d5209e495415c90b21375686e1f8d65d9d2080d3d3aa2677344a48cbc1167e4e83bff6a27223e6f12d891a6416ad122b54ee0d36832dd08311
|
7
|
+
data.tar.gz: 8b0fd57ee957d8eb8d58d3556f7f4ab145a7eb5cac82286c2cefd439a77bc28f6e03abe38484154b308b59b3b30e69bd749fec968fe053ebb0b4f9560ae0eb17
|
data/README.md
CHANGED
@@ -42,7 +42,7 @@ Here is a very high-level overview of how Hooks works:
|
|
42
42
|
health_path: /health
|
43
43
|
version_path: /version
|
44
44
|
|
45
|
-
environment: development
|
45
|
+
environment: development # will be overridden by the RACK_ENV environment variable if set
|
46
46
|
```
|
47
47
|
|
48
48
|
2. Then in your `config/endpoints` directory, you can define all your webhook endpoints in separate files. Here is an example of a simple endpoint configuration file:
|
@@ -153,17 +153,22 @@ This example will assume the following directory structure:
|
|
153
153
|
|
154
154
|
```text
|
155
155
|
├── config/
|
156
|
-
│ ├── hooks.yml
|
157
|
-
│ ├── puma.rb
|
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/
|
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
|
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
|
@@ -189,7 +196,7 @@ health_path: /health
|
|
189
196
|
version_path: /version
|
190
197
|
|
191
198
|
# Runtime behavior
|
192
|
-
environment: development # or production
|
199
|
+
environment: development # or production (will be overridden by the RACK_ENV environment variable if set)
|
193
200
|
```
|
194
201
|
|
195
202
|
#### 2. Create your endpoint configurations
|
@@ -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.
|
@@ -16,7 +16,7 @@ module Hooks
|
|
16
16
|
root_path: "/webhooks",
|
17
17
|
health_path: "/health",
|
18
18
|
version_path: "/version",
|
19
|
-
environment: "production",
|
19
|
+
environment: ENV.fetch("RACK_ENV", "production"),
|
20
20
|
production: true,
|
21
21
|
endpoints_dir: "./config/endpoints",
|
22
22
|
use_catchall_route: false,
|
@@ -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
|
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
|
-
|
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
|
100
|
-
raw_signature =
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
70
|
-
raw_secret =
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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,
|
data/lib/hooks/version.rb
CHANGED