hooks-ruby 0.1.0 → 0.2.1
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 +3 -3
- data/lib/hooks/app/api.rb +48 -31
- data/lib/hooks/app/auth/auth.rb +21 -6
- data/lib/hooks/app/endpoints/catchall.rb +35 -17
- data/lib/hooks/app/helpers.rb +12 -9
- data/lib/hooks/app/rack_env_builder.rb +85 -0
- data/lib/hooks/core/config_validator.rb +5 -0
- data/lib/hooks/plugins/auth/base.rb +59 -0
- data/lib/hooks/plugins/auth/hmac.rb +112 -19
- data/lib/hooks/plugins/auth/shared_secret.rb +7 -21
- data/lib/hooks/plugins/handlers/base.rb +25 -1
- data/lib/hooks/plugins/handlers/default.rb +7 -1
- data/lib/hooks/plugins/handlers/error.rb +36 -0
- data/lib/hooks/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 47555e043e83129f208e5925c22dbbf12aadba0358a70272461952355251c869
|
4
|
+
data.tar.gz: b2760979c328b79cf82fe8b5a37a67261f2b5085208488ca928662e1915d9f06
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 90b666eb68b986092e56e6db8140af3e6aae5b03a59e0e546beeee4a314a97ed75aaf4122687087cc58b258d88462a6fc5829339f56510e9d123f9b0d7c414ed
|
7
|
+
data.tar.gz: e50173bf684c3b458fe489667b05a65737658160c5a01603f7b8061419945fe41b07d04e61861524c8af5b445e2d63aa78b90ac1a3a07bb2fed481c50cc04406
|
data/README.md
CHANGED
@@ -58,7 +58,7 @@ Here is a very high-level overview of how Hooks works:
|
|
58
58
|
```ruby
|
59
59
|
# file: plugins/handlers/my_custom_handler.rb
|
60
60
|
class MyCustomHandler < Hooks::Plugins::Handlers::Base
|
61
|
-
def call(payload:, headers:, config:)
|
61
|
+
def call(payload:, headers:, env:, config:)
|
62
62
|
# Process the incoming webhook - optionally use the payload and headers
|
63
63
|
# to perform some action or validation
|
64
64
|
# For this example, we will just return a success message
|
@@ -233,7 +233,7 @@ Create custom handler plugins in the `plugins/handlers` directory to process inc
|
|
233
233
|
```ruby
|
234
234
|
# file: plugins/handlers/hello_handler.rb
|
235
235
|
class HelloHandler < Hooks::Plugins::Handlers::Base
|
236
|
-
def call(payload:, headers:, config:)
|
236
|
+
def call(payload:, headers:, env:, config:)
|
237
237
|
# Process the incoming webhook - optionally use the payload and headers
|
238
238
|
# to perform some action or validation
|
239
239
|
# For this example, we will just return a success message
|
@@ -251,7 +251,7 @@ And another handler plugin for the `/goodbye` endpoint:
|
|
251
251
|
```ruby
|
252
252
|
# file: plugins/handlers/goodbye_handler.rb
|
253
253
|
class GoodbyeHandler < Hooks::Plugins::Handlers::Base
|
254
|
-
def call(payload:, headers:, config:)
|
254
|
+
def call(payload:, headers:, env:, config:)
|
255
255
|
# Ditto for the goodbye endpoint
|
256
256
|
{
|
257
257
|
message: "goodbye webhook processed successfully",
|
data/lib/hooks/app/api.rb
CHANGED
@@ -5,7 +5,9 @@ require "json"
|
|
5
5
|
require "securerandom"
|
6
6
|
require_relative "helpers"
|
7
7
|
require_relative "auth/auth"
|
8
|
+
require_relative "rack_env_builder"
|
8
9
|
require_relative "../plugins/handlers/base"
|
10
|
+
require_relative "../plugins/handlers/error"
|
9
11
|
require_relative "../plugins/handlers/default"
|
10
12
|
require_relative "../core/logger_factory"
|
11
13
|
require_relative "../core/log"
|
@@ -65,41 +67,27 @@ module Hooks
|
|
65
67
|
Core::LogContext.with(request_context) do
|
66
68
|
begin
|
67
69
|
# Build Rack environment for lifecycle hooks
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
"CONTENT_LENGTH" => request.content_length,
|
78
|
-
"REMOTE_ADDR" => request.env["REMOTE_ADDR"],
|
79
|
-
"hooks.request_id" => request_id,
|
80
|
-
"hooks.handler" => handler_class_name,
|
81
|
-
"hooks.endpoint_config" => endpoint_config,
|
82
|
-
"hooks.start_time" => start_time.iso8601,
|
83
|
-
"hooks.full_path" => full_path
|
84
|
-
}
|
85
|
-
|
86
|
-
# Add HTTP headers to environment
|
87
|
-
headers.each do |key, value|
|
88
|
-
env_key = "HTTP_#{key.upcase.tr('-', '_')}"
|
89
|
-
rack_env[env_key] = value
|
90
|
-
end
|
70
|
+
rack_env_builder = RackEnvBuilder.new(
|
71
|
+
request,
|
72
|
+
headers,
|
73
|
+
request_context,
|
74
|
+
endpoint_config,
|
75
|
+
start_time,
|
76
|
+
full_path
|
77
|
+
)
|
78
|
+
rack_env = rack_env_builder.build
|
91
79
|
|
92
80
|
# Call lifecycle hooks: on_request
|
93
81
|
Core::PluginLoader.lifecycle_plugins.each do |plugin|
|
94
82
|
plugin.on_request(rack_env)
|
95
83
|
end
|
96
84
|
|
97
|
-
enforce_request_limits(config)
|
85
|
+
enforce_request_limits(config, request_context)
|
98
86
|
request.body.rewind
|
99
87
|
raw_body = request.body.read
|
100
88
|
|
101
89
|
if endpoint_config[:auth]
|
102
|
-
validate_auth!(raw_body, headers, endpoint_config, config)
|
90
|
+
validate_auth!(raw_body, headers, endpoint_config, config, request_context)
|
103
91
|
end
|
104
92
|
|
105
93
|
payload = parse_payload(raw_body, headers, symbolize: false)
|
@@ -109,6 +97,7 @@ module Hooks
|
|
109
97
|
response = handler.call(
|
110
98
|
payload:,
|
111
99
|
headers: processed_headers,
|
100
|
+
env: rack_env,
|
112
101
|
config: endpoint_config
|
113
102
|
)
|
114
103
|
|
@@ -122,22 +111,50 @@ module Hooks
|
|
122
111
|
status 200
|
123
112
|
content_type "application/json"
|
124
113
|
response.to_json
|
114
|
+
rescue Hooks::Plugins::Handlers::Error => e
|
115
|
+
# Handler called error! method - immediately return error response and exit the request
|
116
|
+
log.debug("handler #{handler_class_name} called `error!` method")
|
117
|
+
|
118
|
+
error_response = nil
|
119
|
+
|
120
|
+
status e.status
|
121
|
+
case e.body
|
122
|
+
when String
|
123
|
+
content_type "text/plain"
|
124
|
+
error_response = e.body
|
125
|
+
else
|
126
|
+
content_type "application/json"
|
127
|
+
error_response = e.body.to_json
|
128
|
+
end
|
129
|
+
|
130
|
+
return error_response
|
125
131
|
rescue StandardError => e
|
126
|
-
|
132
|
+
err_msg = "Error processing webhook event with handler: #{handler_class_name} - #{e.message} " \
|
133
|
+
"- request_id: #{request_id} - path: #{full_path} - method: #{http_method} - " \
|
134
|
+
"backtrace: #{e.backtrace.join("\n")}"
|
135
|
+
log.error(err_msg)
|
136
|
+
|
137
|
+
# call lifecycle hooks: on_error if the rack_env is available
|
138
|
+
# if the rack_env is not available, it means the error occurred before we could build it
|
127
139
|
if defined?(rack_env)
|
128
140
|
Core::PluginLoader.lifecycle_plugins.each do |plugin|
|
129
141
|
plugin.on_error(e, rack_env)
|
130
142
|
end
|
131
143
|
end
|
132
144
|
|
133
|
-
|
145
|
+
# construct a standardized error response
|
134
146
|
error_response = {
|
135
|
-
error:
|
136
|
-
|
147
|
+
error: "server_error",
|
148
|
+
message: "an unexpected error occurred while processing the request",
|
137
149
|
request_id:
|
138
150
|
}
|
139
|
-
|
140
|
-
|
151
|
+
|
152
|
+
# enrich the error response with details if not in production
|
153
|
+
error_response[:backtrace] = e.backtrace.join("\n") unless config[:production]
|
154
|
+
error_response[:message] = e.message unless config[:production]
|
155
|
+
error_response[:handler] = handler_class_name unless config[:production]
|
156
|
+
|
157
|
+
status determine_error_code(e)
|
141
158
|
content_type "application/json"
|
142
159
|
error_response.to_json
|
143
160
|
end
|
data/lib/hooks/app/auth/auth.rb
CHANGED
@@ -16,30 +16,45 @@ module Hooks
|
|
16
16
|
# @param headers [Hash] The request headers.
|
17
17
|
# @param endpoint_config [Hash] The endpoint configuration, must include :auth key.
|
18
18
|
# @param global_config [Hash] The global configuration (optional, for compatibility).
|
19
|
+
# @param request_context [Hash] Context for the request, e.g. request ID, path, handler (optional).
|
19
20
|
# @raise [StandardError] Raises error if authentication fails or is misconfigured.
|
20
21
|
# @return [void]
|
21
22
|
# @note This method will halt execution with an error if authentication fails.
|
22
|
-
def validate_auth!(payload, headers, endpoint_config, global_config = {})
|
23
|
+
def validate_auth!(payload, headers, endpoint_config, global_config = {}, request_context = {})
|
23
24
|
auth_config = endpoint_config[:auth]
|
25
|
+
request_id = request_context&.dig(:request_id)
|
24
26
|
|
25
27
|
# Ensure auth type is present and valid
|
26
28
|
auth_type = auth_config&.dig(:type)
|
27
29
|
unless auth_type&.is_a?(String) && !auth_type.strip.empty?
|
28
|
-
error
|
30
|
+
log.error("authentication configuration missing or invalid - request_id: #{request_id}")
|
31
|
+
error!({
|
32
|
+
error: "authentication_configuration_error",
|
33
|
+
message: "authentication configuration missing or invalid",
|
34
|
+
request_id:
|
35
|
+
}, 500)
|
29
36
|
end
|
30
37
|
|
31
38
|
# Get auth plugin from loaded plugins registry (boot-time loaded only)
|
32
39
|
begin
|
33
40
|
auth_class = Core::PluginLoader.get_auth_plugin(auth_type)
|
34
41
|
rescue => e
|
35
|
-
log.error("failed to load auth plugin '#{auth_type}': #{e.message}")
|
36
|
-
error!(
|
42
|
+
log.error("failed to load auth plugin '#{auth_type}': #{e.message} - request_id: #{request_id}")
|
43
|
+
error!({
|
44
|
+
error: "authentication_plugin_error",
|
45
|
+
message: "unsupported auth type '#{auth_type}'",
|
46
|
+
request_id:
|
47
|
+
}, 400)
|
37
48
|
end
|
38
49
|
|
39
50
|
log.debug("validating auth for request with auth_class: #{auth_class.name}")
|
40
51
|
unless auth_class.valid?(payload:, headers:, config: endpoint_config)
|
41
|
-
log.warn("authentication failed for request with auth_class: #{auth_class.name}")
|
42
|
-
error!(
|
52
|
+
log.warn("authentication failed for request with auth_class: #{auth_class.name} - request_id: #{request_id}")
|
53
|
+
error!({
|
54
|
+
error: "authentication_failed",
|
55
|
+
message: "authentication failed",
|
56
|
+
request_id:
|
57
|
+
}, 401)
|
43
58
|
end
|
44
59
|
end
|
45
60
|
|
@@ -27,20 +27,36 @@ module Hooks
|
|
27
27
|
# :nocov:
|
28
28
|
proc do
|
29
29
|
request_id = uuid
|
30
|
+
start_time = Time.now
|
30
31
|
|
31
32
|
# Use captured values
|
32
33
|
config = captured_config
|
33
34
|
log = captured_logger
|
34
35
|
|
36
|
+
full_path = "#{config[:root_path]}/#{params[:path]}"
|
37
|
+
|
38
|
+
handler_class_name = "DefaultHandler"
|
39
|
+
http_method = "post"
|
40
|
+
|
35
41
|
# Set request context for logging
|
36
42
|
request_context = {
|
37
43
|
request_id:,
|
38
|
-
path:
|
39
|
-
handler:
|
44
|
+
path: full_path,
|
45
|
+
handler: handler_class_name
|
40
46
|
}
|
41
47
|
|
42
48
|
Hooks::Core::LogContext.with(request_context) do
|
43
49
|
begin
|
50
|
+
rack_env_builder = RackEnvBuilder.new(
|
51
|
+
request,
|
52
|
+
headers,
|
53
|
+
request_context,
|
54
|
+
config,
|
55
|
+
start_time,
|
56
|
+
full_path
|
57
|
+
)
|
58
|
+
rack_env = rack_env_builder.build
|
59
|
+
|
44
60
|
# Enforce request limits
|
45
61
|
enforce_request_limits(config)
|
46
62
|
|
@@ -58,32 +74,34 @@ module Hooks
|
|
58
74
|
response = handler.call(
|
59
75
|
payload:,
|
60
76
|
headers:,
|
77
|
+
env: rack_env,
|
61
78
|
config: {}
|
62
79
|
)
|
63
80
|
|
64
|
-
log.info
|
65
|
-
|
66
|
-
# Return response as JSON string when using txt format
|
81
|
+
log.info("successfully processed webhook event with handler: #{handler_class_name}")
|
82
|
+
log.debug("processing duration: #{Time.now - start_time}s")
|
67
83
|
status 200
|
68
84
|
content_type "application/json"
|
69
|
-
|
70
|
-
|
85
|
+
response.to_json
|
71
86
|
rescue StandardError => e
|
72
|
-
|
87
|
+
err_msg = "Error processing webhook event with handler: #{handler_class_name} - #{e.message} " \
|
88
|
+
"- request_id: #{request_id} - path: #{full_path} - method: #{http_method} - " \
|
89
|
+
"backtrace: #{e.backtrace.join("\n")}"
|
90
|
+
log.error(err_msg)
|
73
91
|
|
74
|
-
#
|
92
|
+
# construct a standardized error response
|
75
93
|
error_response = {
|
76
|
-
error:
|
77
|
-
|
78
|
-
request_id:
|
94
|
+
error: "server_error",
|
95
|
+
message: "an unexpected error occurred while processing the request",
|
96
|
+
request_id:
|
79
97
|
}
|
80
98
|
|
81
|
-
#
|
82
|
-
unless config[:production]
|
83
|
-
|
84
|
-
|
99
|
+
# enrich the error response with details if not in production
|
100
|
+
error_response[:backtrace] = e.backtrace.join("\n") unless config[:production]
|
101
|
+
error_response[:message] = e.message unless config[:production]
|
102
|
+
error_response[:handler] = handler_class_name unless config[:production]
|
85
103
|
|
86
|
-
status
|
104
|
+
status determine_error_code(e)
|
87
105
|
content_type "application/json"
|
88
106
|
error_response.to_json
|
89
107
|
end
|
data/lib/hooks/app/helpers.rb
CHANGED
@@ -17,10 +17,11 @@ module Hooks
|
|
17
17
|
# Enforce request size and timeout limits
|
18
18
|
#
|
19
19
|
# @param config [Hash] The configuration hash, must include :request_limit
|
20
|
+
# @param request_context [Hash] Context for the request, e.g. request ID (optional)
|
20
21
|
# @raise [StandardError] Halts with error if request body is too large
|
21
22
|
# @return [void]
|
22
23
|
# @note Timeout enforcement should be handled at the server level (e.g., Puma)
|
23
|
-
def enforce_request_limits(config)
|
24
|
+
def enforce_request_limits(config, request_context = {})
|
24
25
|
# Optimized content length check - check most common sources first
|
25
26
|
content_length = request.content_length if respond_to?(:request) && request.respond_to?(:content_length)
|
26
27
|
|
@@ -34,7 +35,8 @@ module Hooks
|
|
34
35
|
content_length = content_length&.to_i
|
35
36
|
|
36
37
|
if content_length && content_length > config[:request_limit]
|
37
|
-
|
38
|
+
request_id = request_context&.dig(:request_id)
|
39
|
+
error!({ error: "request_body_too_large", message: "request body too large", request_id: }, 413)
|
38
40
|
end
|
39
41
|
|
40
42
|
# Note: Timeout enforcement would typically be handled at the server level (Puma, etc.)
|
@@ -76,13 +78,14 @@ module Hooks
|
|
76
78
|
# @return [Object] An instance of the loaded handler class
|
77
79
|
# @raise [StandardError] If handler cannot be found
|
78
80
|
def load_handler(handler_class_name)
|
79
|
-
# Get handler class from loaded plugins registry (boot
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
81
|
+
# Get handler class from loaded plugins registry (the registry is populated at boot time)
|
82
|
+
# NOTE: We create a new instance per request (not reuse boot-time instances) because:
|
83
|
+
# - Security: Prevents state pollution and information leakage between requests
|
84
|
+
# - Thread Safety: Avoids race conditions from shared instance state
|
85
|
+
# - Performance: Handler instantiation is fast; reusing instances provides minimal gain
|
86
|
+
# - Memory: Allows garbage collection of short-lived objects (Ruby GC optimization)
|
87
|
+
handler_class = Core::PluginLoader.get_handler_plugin(handler_class_name)
|
88
|
+
return handler_class.new
|
86
89
|
end
|
87
90
|
|
88
91
|
private
|
@@ -0,0 +1,85 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Hooks
|
4
|
+
module App
|
5
|
+
# Builds Rack environment hash for lifecycle hooks and handler processing
|
6
|
+
#
|
7
|
+
# This class centralizes the construction of the Rack environment that gets
|
8
|
+
# passed to lifecycle hooks and handlers, ensuring consistency and making
|
9
|
+
# it easy to reference the environment structure.
|
10
|
+
#
|
11
|
+
# @example Building a Rack environment
|
12
|
+
# builder = RackEnvBuilder.new(request, headers, request_context)
|
13
|
+
# rack_env = builder.build
|
14
|
+
#
|
15
|
+
class RackEnvBuilder
|
16
|
+
# Initialize the builder with required components
|
17
|
+
#
|
18
|
+
# @param request [Grape::Request] The Grape request object
|
19
|
+
# @param headers [Hash] Request headers hash
|
20
|
+
# @param request_context [Hash] Request context containing metadata
|
21
|
+
# @option request_context [String] :request_id Unique request identifier
|
22
|
+
# @option request_context [String] :handler Handler class name
|
23
|
+
# @param endpoint_config [Hash] Endpoint configuration
|
24
|
+
# @param start_time [Time] Request start time
|
25
|
+
# @param full_path [String] Full request path including root path
|
26
|
+
def initialize(request, headers, request_context, endpoint_config, start_time, full_path)
|
27
|
+
@request = request
|
28
|
+
@headers = headers
|
29
|
+
@request_context = request_context
|
30
|
+
@endpoint_config = endpoint_config
|
31
|
+
@start_time = start_time
|
32
|
+
@full_path = full_path
|
33
|
+
end
|
34
|
+
|
35
|
+
# Build the Rack environment hash
|
36
|
+
#
|
37
|
+
# Constructs a hash containing standard Rack environment variables
|
38
|
+
# plus Hooks-specific extensions for lifecycle hooks and handlers.
|
39
|
+
#
|
40
|
+
# @return [Hash] Complete Rack environment hash
|
41
|
+
def build
|
42
|
+
rack_env = build_base_environment
|
43
|
+
add_http_headers(rack_env)
|
44
|
+
rack_env
|
45
|
+
end
|
46
|
+
|
47
|
+
private
|
48
|
+
|
49
|
+
# Build the base Rack environment with standard and Hooks-specific variables
|
50
|
+
# This pretty much creates everything plus the kitchen sink. It will be very rich in information
|
51
|
+
# and will be used by lifecycle hooks and handlers to access request metadata.
|
52
|
+
#
|
53
|
+
# @return [Hash] Base environment hash
|
54
|
+
def build_base_environment
|
55
|
+
{
|
56
|
+
"REQUEST_METHOD" => @request.request_method,
|
57
|
+
"PATH_INFO" => @request.path_info,
|
58
|
+
"QUERY_STRING" => @request.query_string,
|
59
|
+
"HTTP_VERSION" => @request.env["HTTP_VERSION"],
|
60
|
+
"REQUEST_URI" => @request.url,
|
61
|
+
"SERVER_NAME" => @request.env["SERVER_NAME"],
|
62
|
+
"SERVER_PORT" => @request.env["SERVER_PORT"],
|
63
|
+
"CONTENT_TYPE" => @request.content_type,
|
64
|
+
"CONTENT_LENGTH" => @request.content_length,
|
65
|
+
"REMOTE_ADDR" => @request.env["REMOTE_ADDR"],
|
66
|
+
"hooks.request_id" => @request_context[:request_id],
|
67
|
+
"hooks.handler" => @request_context[:handler],
|
68
|
+
"hooks.endpoint_config" => @endpoint_config,
|
69
|
+
"hooks.start_time" => @start_time.iso8601,
|
70
|
+
"hooks.full_path" => @full_path
|
71
|
+
}
|
72
|
+
end
|
73
|
+
|
74
|
+
# Add HTTP headers to the environment with proper Rack naming convention
|
75
|
+
#
|
76
|
+
# @param rack_env [Hash] Environment hash to modify
|
77
|
+
def add_http_headers(rack_env)
|
78
|
+
@headers.each do |key, value|
|
79
|
+
env_key = "HTTP_#{key.upcase.tr('-', '_')}"
|
80
|
+
rack_env[env_key] = value
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
@@ -45,6 +45,11 @@ module Hooks
|
|
45
45
|
optional(:format).filled(:string)
|
46
46
|
optional(:version_prefix).filled(:string)
|
47
47
|
optional(:payload_template).filled(:string)
|
48
|
+
optional(:header_format).filled(:string)
|
49
|
+
optional(:signature_key).filled(:string)
|
50
|
+
optional(:timestamp_key).filled(:string)
|
51
|
+
optional(:structured_header_separator).filled(:string)
|
52
|
+
optional(:key_value_separator).filled(:string)
|
48
53
|
end
|
49
54
|
|
50
55
|
optional(:opts).hash
|
@@ -14,6 +14,10 @@ module Hooks
|
|
14
14
|
class Base
|
15
15
|
extend Hooks::Core::ComponentAccess
|
16
16
|
|
17
|
+
# Security constants shared across auth validators
|
18
|
+
MAX_HEADER_VALUE_LENGTH = ENV.fetch("HOOKS_MAX_HEADER_VALUE_LENGTH", 1024).to_i # Prevent DoS attacks via large header values
|
19
|
+
MAX_PAYLOAD_SIZE = ENV.fetch("HOOKS_MAX_PAYLOAD_SIZE", 10 * 1024 * 1024).to_i # 10MB limit for payload validation
|
20
|
+
|
17
21
|
# Validate request
|
18
22
|
#
|
19
23
|
# @param payload [String] Raw request body
|
@@ -67,6 +71,61 @@ module Hooks
|
|
67
71
|
end
|
68
72
|
nil
|
69
73
|
end
|
74
|
+
|
75
|
+
# Validate headers object for security issues
|
76
|
+
#
|
77
|
+
# @param headers [Object] Headers to validate
|
78
|
+
# @return [Boolean] true if headers are valid
|
79
|
+
def self.valid_headers?(headers)
|
80
|
+
unless headers.respond_to?(:each)
|
81
|
+
log.warn("Auth validation failed: Invalid headers object")
|
82
|
+
return false
|
83
|
+
end
|
84
|
+
true
|
85
|
+
end
|
86
|
+
|
87
|
+
# Validate payload size for security issues
|
88
|
+
#
|
89
|
+
# @param payload [String] Payload to validate
|
90
|
+
# @return [Boolean] true if payload is valid
|
91
|
+
def self.valid_payload_size?(payload)
|
92
|
+
return true if payload.nil?
|
93
|
+
|
94
|
+
if payload.bytesize > MAX_PAYLOAD_SIZE
|
95
|
+
log.warn("Auth validation failed: Payload size exceeds maximum limit of #{MAX_PAYLOAD_SIZE} bytes")
|
96
|
+
return false
|
97
|
+
end
|
98
|
+
true
|
99
|
+
end
|
100
|
+
|
101
|
+
# Validate header value for security issues
|
102
|
+
#
|
103
|
+
# @param header_value [String] Header value to validate
|
104
|
+
# @param header_name [String] Header name for logging
|
105
|
+
# @return [Boolean] true if header value is valid
|
106
|
+
def self.valid_header_value?(header_value, header_name)
|
107
|
+
return false if header_value.nil? || header_value.empty?
|
108
|
+
|
109
|
+
# Check length to prevent DoS
|
110
|
+
if header_value.length > MAX_HEADER_VALUE_LENGTH
|
111
|
+
log.warn("Auth validation failed: #{header_name} exceeds maximum length")
|
112
|
+
return false
|
113
|
+
end
|
114
|
+
|
115
|
+
# Check for whitespace tampering
|
116
|
+
if header_value != header_value.strip
|
117
|
+
log.warn("Auth validation failed: #{header_name} contains leading/trailing whitespace")
|
118
|
+
return false
|
119
|
+
end
|
120
|
+
|
121
|
+
# Check for control characters
|
122
|
+
if header_value.match?(/[\u0000-\u001f\u007f-\u009f]/)
|
123
|
+
log.warn("Auth validation failed: #{header_name} contains control characters")
|
124
|
+
return false
|
125
|
+
end
|
126
|
+
|
127
|
+
true
|
128
|
+
end
|
70
129
|
end
|
71
130
|
end
|
72
131
|
end
|
@@ -32,7 +32,23 @@ module Hooks
|
|
32
32
|
# format: "version=signature"
|
33
33
|
# version_prefix: "v0"
|
34
34
|
# payload_template: "{version}:{timestamp}:{body}"
|
35
|
+
#
|
36
|
+
# @example Configuration for Tailscale-style structured headers
|
37
|
+
# auth:
|
38
|
+
# type: HMAC
|
39
|
+
# secret_env_key: WEBHOOK_SECRET
|
40
|
+
# header: Tailscale-Webhook-Signature
|
41
|
+
# algorithm: sha256
|
42
|
+
# format: "signature_only"
|
43
|
+
# header_format: "structured"
|
44
|
+
# signature_key: "v1"
|
45
|
+
# timestamp_key: "t"
|
46
|
+
# payload_template: "{timestamp}.{body}"
|
47
|
+
# timestamp_tolerance: 300 # 5 minutes
|
35
48
|
class HMAC < Base
|
49
|
+
# Security constants
|
50
|
+
MAX_SIGNATURE_LENGTH = ENV.fetch("HOOKS_MAX_SIGNATURE_LENGTH", 1024).to_i # Prevent DoS attacks via large signatures
|
51
|
+
|
36
52
|
# Default configuration values for HMAC validation
|
37
53
|
#
|
38
54
|
# @return [Hash<Symbol, String|Integer>] Default configuration settings
|
@@ -42,7 +58,8 @@ module Hooks
|
|
42
58
|
format: "algorithm=signature", # Format: algorithm=hash
|
43
59
|
header: "X-Signature", # Default header containing the signature
|
44
60
|
timestamp_tolerance: 300, # 5 minutes tolerance for timestamp validation
|
45
|
-
version_prefix: "v0"
|
61
|
+
version_prefix: "v0", # Default version prefix for versioned signatures
|
62
|
+
header_format: "simple" # Header format: "simple" or "structured"
|
46
63
|
}.freeze
|
47
64
|
|
48
65
|
# Mapping of signature format strings to internal format symbols
|
@@ -75,6 +92,11 @@ module Hooks
|
|
75
92
|
# @option config [String] :format ('algorithm=signature') Signature format
|
76
93
|
# @option config [String] :version_prefix ('v0') Version prefix for versioned signatures
|
77
94
|
# @option config [String] :payload_template Template for payload construction
|
95
|
+
# @option config [String] :header_format ('simple') Header format: 'simple' or 'structured'
|
96
|
+
# @option config [String] :signature_key ('v1') Key for signature in structured headers
|
97
|
+
# @option config [String] :timestamp_key ('t') Key for timestamp in structured headers
|
98
|
+
# @option config [String] :structured_header_separator (',') Separator for structured headers
|
99
|
+
# @option config [String] :key_value_separator ('=') Separator for key-value pairs in structured headers
|
78
100
|
# @return [Boolean] true if signature is valid, false otherwise
|
79
101
|
# @raise [StandardError] Rescued internally, returns false on any error
|
80
102
|
# @note This method is designed to be safe and will never raise exceptions
|
@@ -91,11 +113,9 @@ module Hooks
|
|
91
113
|
|
92
114
|
validator_config = build_config(config)
|
93
115
|
|
94
|
-
# Security: Check raw headers
|
95
|
-
unless
|
96
|
-
|
97
|
-
return false
|
98
|
-
end
|
116
|
+
# Security: Check raw headers and payload BEFORE processing
|
117
|
+
return false unless valid_headers?(headers)
|
118
|
+
return false unless valid_payload_size?(payload)
|
99
119
|
|
100
120
|
signature_header = validator_config[:header]
|
101
121
|
|
@@ -107,23 +127,37 @@ module Hooks
|
|
107
127
|
return false
|
108
128
|
end
|
109
129
|
|
110
|
-
#
|
111
|
-
|
112
|
-
log.warn("Auth::HMAC validation failed: Signature contains leading/trailing whitespace")
|
113
|
-
return false
|
114
|
-
end
|
115
|
-
|
116
|
-
# Security: Reject signatures containing null bytes or other control characters
|
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
|
130
|
+
# Validate signature format using shared validation but with HMAC-specific length limit
|
131
|
+
return false unless validate_signature_format(raw_signature)
|
121
132
|
|
122
133
|
# Now we can safely normalize headers for the rest of the validation
|
123
134
|
normalized_headers = normalize_headers(headers)
|
124
|
-
|
135
|
+
|
136
|
+
# Handle structured headers (e.g., Tailscale format: "t=123,v1=abc")
|
137
|
+
if validator_config[:header_format] == "structured"
|
138
|
+
parsed_signature_data = parse_structured_header(raw_signature, validator_config)
|
139
|
+
if parsed_signature_data.nil?
|
140
|
+
log.warn("Auth::HMAC validation failed: Could not parse structured signature header")
|
141
|
+
return false
|
142
|
+
end
|
143
|
+
|
144
|
+
provided_signature = parsed_signature_data[:signature]
|
145
|
+
|
146
|
+
# For structured headers, timestamp comes from the signature header itself
|
147
|
+
if parsed_signature_data[:timestamp]
|
148
|
+
normalized_headers = normalized_headers.merge(
|
149
|
+
"extracted_timestamp" => parsed_signature_data[:timestamp]
|
150
|
+
)
|
151
|
+
# Override timestamp_header to use our extracted timestamp
|
152
|
+
validator_config = validator_config.merge(timestamp_header: "extracted_timestamp")
|
153
|
+
end
|
154
|
+
else
|
155
|
+
provided_signature = normalized_headers[signature_header.downcase]
|
156
|
+
end
|
125
157
|
|
126
158
|
# Validate timestamp if required (for services that include timestamp validation)
|
159
|
+
# It should be noted that not all HMAC implementations require timestamp validation,
|
160
|
+
# so this is optional based on configuration.
|
127
161
|
if validator_config[:timestamp_header]
|
128
162
|
unless valid_timestamp?(normalized_headers, validator_config)
|
129
163
|
log.warn("Auth::HMAC validation failed: Invalid timestamp")
|
@@ -154,6 +188,22 @@ module Hooks
|
|
154
188
|
|
155
189
|
private
|
156
190
|
|
191
|
+
# Validate signature format for HMAC (uses HMAC-specific length limit)
|
192
|
+
#
|
193
|
+
# @param signature [String] Raw signature to validate
|
194
|
+
# @return [Boolean] true if signature is valid
|
195
|
+
# @api private
|
196
|
+
def self.validate_signature_format(signature)
|
197
|
+
# Check signature length with HMAC-specific limit
|
198
|
+
if signature.length > MAX_SIGNATURE_LENGTH
|
199
|
+
log.warn("Auth::HMAC validation failed: Signature length exceeds maximum limit of #{MAX_SIGNATURE_LENGTH} characters")
|
200
|
+
return false
|
201
|
+
end
|
202
|
+
|
203
|
+
# Use shared validation for other checks
|
204
|
+
valid_header_value?(signature, "Signature")
|
205
|
+
end
|
206
|
+
|
157
207
|
# Build final configuration by merging defaults with provided config
|
158
208
|
#
|
159
209
|
# Combines default configuration values with user-provided settings,
|
@@ -176,7 +226,12 @@ module Hooks
|
|
176
226
|
algorithm: algorithm,
|
177
227
|
format: validator_config[:format] || DEFAULT_CONFIG[:format],
|
178
228
|
version_prefix: validator_config[:version_prefix] || DEFAULT_CONFIG[:version_prefix],
|
179
|
-
payload_template: validator_config[:payload_template]
|
229
|
+
payload_template: validator_config[:payload_template],
|
230
|
+
header_format: validator_config[:header_format] || DEFAULT_CONFIG[:header_format],
|
231
|
+
signature_key: validator_config[:signature_key] || "v1",
|
232
|
+
timestamp_key: validator_config[:timestamp_key] || "t",
|
233
|
+
structured_header_separator: validator_config[:structured_header_separator] || ",",
|
234
|
+
key_value_separator: validator_config[:key_value_separator] || "="
|
180
235
|
})
|
181
236
|
end
|
182
237
|
|
@@ -321,6 +376,44 @@ module Hooks
|
|
321
376
|
"#{config[:algorithm]}=#{hash}"
|
322
377
|
end
|
323
378
|
end
|
379
|
+
|
380
|
+
# Parse structured signature header containing comma-separated key-value pairs
|
381
|
+
#
|
382
|
+
# Parses signature headers like "t=1663781880,v1=0123456789abcdef..." used by
|
383
|
+
# providers like Tailscale that include multiple values in a single header.
|
384
|
+
#
|
385
|
+
# @param header_value [String] Raw signature header value
|
386
|
+
# @param config [Hash<Symbol, Object>] Validator configuration
|
387
|
+
# @return [Hash<Symbol, String>, nil] Parsed data with :signature and :timestamp keys, or nil if parsing fails
|
388
|
+
# @note Returns nil if the header format is invalid or required keys are missing
|
389
|
+
# @api private
|
390
|
+
def self.parse_structured_header(header_value, config)
|
391
|
+
signature_key = config[:signature_key]
|
392
|
+
timestamp_key = config[:timestamp_key]
|
393
|
+
separator = config[:structured_header_separator]
|
394
|
+
key_value_separator = config[:key_value_separator]
|
395
|
+
|
396
|
+
# Parse comma-separated key-value pairs
|
397
|
+
pairs = {}
|
398
|
+
header_value.split(separator).each do |pair|
|
399
|
+
key, value = pair.split(key_value_separator, 2)
|
400
|
+
return nil if key.nil? || value.nil?
|
401
|
+
|
402
|
+
pairs[key.strip] = value.strip
|
403
|
+
end
|
404
|
+
|
405
|
+
# Extract required signature
|
406
|
+
signature = pairs[signature_key]
|
407
|
+
return nil if signature.nil? || signature.empty?
|
408
|
+
|
409
|
+
result = { signature: signature }
|
410
|
+
|
411
|
+
# Extract optional timestamp
|
412
|
+
timestamp = pairs[timestamp_key]
|
413
|
+
result[:timestamp] = timestamp if timestamp && !timestamp.empty?
|
414
|
+
|
415
|
+
result
|
416
|
+
end
|
324
417
|
end
|
325
418
|
end
|
326
419
|
end
|
@@ -61,11 +61,9 @@ module Hooks
|
|
61
61
|
|
62
62
|
validator_config = build_config(config)
|
63
63
|
|
64
|
-
# Security: Check raw headers
|
65
|
-
unless
|
66
|
-
|
67
|
-
return false
|
68
|
-
end
|
64
|
+
# Security: Check raw headers and payload BEFORE processing
|
65
|
+
return false unless valid_headers?(headers)
|
66
|
+
return false unless valid_payload_size?(payload)
|
69
67
|
|
70
68
|
secret_header = validator_config[:header]
|
71
69
|
|
@@ -77,19 +75,13 @@ module Hooks
|
|
77
75
|
return false
|
78
76
|
end
|
79
77
|
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
if raw_secret != stripped_secret
|
84
|
-
log.warn("Auth::SharedSecret validation failed: Secret contains leading/trailing whitespace")
|
78
|
+
# Validate secret format using shared validation
|
79
|
+
unless valid_header_value?(raw_secret, "Secret")
|
80
|
+
log.warn("Auth::SharedSecret validation failed: Invalid secret format")
|
85
81
|
return false
|
86
82
|
end
|
87
83
|
|
88
|
-
|
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
|
84
|
+
stripped_secret = raw_secret.strip
|
93
85
|
|
94
86
|
# Use secure comparison to prevent timing attacks
|
95
87
|
result = Rack::Utils.secure_compare(secret, stripped_secret)
|
@@ -106,12 +98,6 @@ module Hooks
|
|
106
98
|
|
107
99
|
private
|
108
100
|
|
109
|
-
# Short logger accessor for auth module
|
110
|
-
# @return [Hooks::Log] Logger instance
|
111
|
-
def self.log
|
112
|
-
Hooks::Log.instance
|
113
|
-
end
|
114
|
-
|
115
101
|
# Build final configuration by merging defaults with provided config
|
116
102
|
#
|
117
103
|
# Combines default configuration values with user-provided settings,
|
@@ -2,6 +2,7 @@
|
|
2
2
|
|
3
3
|
require_relative "../../core/global_components"
|
4
4
|
require_relative "../../core/component_access"
|
5
|
+
require_relative "error"
|
5
6
|
|
6
7
|
module Hooks
|
7
8
|
module Plugins
|
@@ -16,12 +17,35 @@ module Hooks
|
|
16
17
|
#
|
17
18
|
# @param payload [Hash, String] Parsed request body (JSON Hash) or raw string
|
18
19
|
# @param headers [Hash] HTTP headers (string keys, optionally normalized - default is normalized)
|
20
|
+
# @param env [Hash] Rack environment (contains the request context, headers, etc - very rich context)
|
19
21
|
# @param config [Hash] Merged endpoint configuration including opts section (symbolized keys)
|
20
22
|
# @return [Hash, String, nil] Response body (will be auto-converted to JSON)
|
21
23
|
# @raise [NotImplementedError] if not implemented by subclass
|
22
|
-
def call(payload:, headers:, config:)
|
24
|
+
def call(payload:, headers:, env:, config:)
|
23
25
|
raise NotImplementedError, "Handler must implement #call method"
|
24
26
|
end
|
27
|
+
|
28
|
+
# Terminate request processing with a custom error response
|
29
|
+
#
|
30
|
+
# This method provides the same interface as Grape's `error!` method,
|
31
|
+
# allowing handlers to immediately stop processing and return a specific
|
32
|
+
# error response to the client.
|
33
|
+
#
|
34
|
+
# @param body [Object] The error body/data to return to the client
|
35
|
+
# @param status [Integer] The HTTP status code to return (default: 500)
|
36
|
+
# @raise [Hooks::Plugins::Handlers::Error] Always raises to terminate processing
|
37
|
+
#
|
38
|
+
# @example Return a custom error with status 400
|
39
|
+
# error!({ error: "validation_failed", message: "Invalid payload" }, 400)
|
40
|
+
#
|
41
|
+
# @example Return a simple string error with status 401
|
42
|
+
# error!("Unauthorized", 401)
|
43
|
+
#
|
44
|
+
# @example Return an error with default 500 status
|
45
|
+
# error!({ error: "internal_error", message: "Something went wrong" })
|
46
|
+
def error!(body, status = 500)
|
47
|
+
raise Error.new(body, status)
|
48
|
+
end
|
25
49
|
end
|
26
50
|
end
|
27
51
|
end
|
@@ -20,6 +20,7 @@ class DefaultHandler < Hooks::Plugins::Handlers::Base
|
|
20
20
|
#
|
21
21
|
# @param payload [Hash, String] The webhook payload (parsed JSON or raw string)
|
22
22
|
# @param headers [Hash<String, String>] HTTP headers from the webhook request
|
23
|
+
# @param env [Hash] Rack environment (contains the request context, headers, config, etc - very rich context)
|
23
24
|
# @param config [Hash] Endpoint configuration containing handler options
|
24
25
|
# @return [Hash] Response indicating successful processing
|
25
26
|
# @option config [Hash] :opts Additional handler-specific configuration options
|
@@ -29,10 +30,11 @@ class DefaultHandler < Hooks::Plugins::Handlers::Base
|
|
29
30
|
# response = handler.call(
|
30
31
|
# payload: { "event" => "push" },
|
31
32
|
# headers: { "Content-Type" => "application/json" },
|
33
|
+
# env: { "REQUEST_METHOD" => "POST", "hooks.request_id" => "12345" },
|
32
34
|
# config: { opts: {} }
|
33
35
|
# )
|
34
36
|
# # => { message: "webhook processed successfully", handler: "DefaultHandler", timestamp: "..." }
|
35
|
-
def call(payload:, headers:, config:)
|
37
|
+
def call(payload:, headers:, env:, config:)
|
36
38
|
|
37
39
|
log.info("🔔 Default handler invoked for webhook 🔔")
|
38
40
|
|
@@ -41,6 +43,10 @@ class DefaultHandler < Hooks::Plugins::Handlers::Base
|
|
41
43
|
log.debug("received payload: #{payload.inspect}")
|
42
44
|
end
|
43
45
|
|
46
|
+
if env
|
47
|
+
log.debug("default handler got a request with the following request_id: #{env['hooks.request_id']}")
|
48
|
+
end
|
49
|
+
|
44
50
|
{
|
45
51
|
message: "webhook processed successfully",
|
46
52
|
handler: "DefaultHandler",
|
@@ -0,0 +1,36 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Hooks
|
4
|
+
module Plugins
|
5
|
+
module Handlers
|
6
|
+
# Custom exception class for handler errors
|
7
|
+
#
|
8
|
+
# This exception is used when handlers call the `error!` method to
|
9
|
+
# immediately terminate request processing and return a specific error response.
|
10
|
+
# It carries the error details back to the Grape API context where it can be
|
11
|
+
# properly formatted and returned to the client.
|
12
|
+
#
|
13
|
+
# @example Usage in handler
|
14
|
+
# error!({ error: "validation_failed", message: "Invalid payload" }, 400)
|
15
|
+
#
|
16
|
+
# @see Hooks::Plugins::Handlers::Base#error!
|
17
|
+
class Error < StandardError
|
18
|
+
# @return [Object] The error body/data to return to the client
|
19
|
+
attr_reader :body
|
20
|
+
|
21
|
+
# @return [Integer] The HTTP status code to return
|
22
|
+
attr_reader :status
|
23
|
+
|
24
|
+
# Initialize a new handler error
|
25
|
+
#
|
26
|
+
# @param body [Object] The error body/data to return to the client
|
27
|
+
# @param status [Integer] The HTTP status code to return (default: 500)
|
28
|
+
def initialize(body, status = 500)
|
29
|
+
@body = body
|
30
|
+
@status = status.to_i
|
31
|
+
super("Handler error: #{status} - #{body}")
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
data/lib/hooks/version.rb
CHANGED
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.1
|
4
|
+
version: 0.2.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- github
|
@@ -129,6 +129,7 @@ files:
|
|
129
129
|
- lib/hooks/app/endpoints/health.rb
|
130
130
|
- lib/hooks/app/endpoints/version.rb
|
131
131
|
- lib/hooks/app/helpers.rb
|
132
|
+
- lib/hooks/app/rack_env_builder.rb
|
132
133
|
- lib/hooks/core/builder.rb
|
133
134
|
- lib/hooks/core/component_access.rb
|
134
135
|
- lib/hooks/core/config_loader.rb
|
@@ -145,6 +146,7 @@ files:
|
|
145
146
|
- lib/hooks/plugins/auth/timestamp_validator.rb
|
146
147
|
- lib/hooks/plugins/handlers/base.rb
|
147
148
|
- lib/hooks/plugins/handlers/default.rb
|
149
|
+
- lib/hooks/plugins/handlers/error.rb
|
148
150
|
- lib/hooks/plugins/instruments/failbot.rb
|
149
151
|
- lib/hooks/plugins/instruments/failbot_base.rb
|
150
152
|
- lib/hooks/plugins/instruments/stats.rb
|