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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2a1dfcc57a6bae5c2e5bd01f7e6165218220f04258ac8fab3ab256b685454bb6
4
- data.tar.gz: 724dbe5463d5bf223ef9652e566b448f38a4270d0131446e302236685ba43710
3
+ metadata.gz: 47555e043e83129f208e5925c22dbbf12aadba0358a70272461952355251c869
4
+ data.tar.gz: b2760979c328b79cf82fe8b5a37a67261f2b5085208488ca928662e1915d9f06
5
5
  SHA512:
6
- metadata.gz: d21c5cce4267f7d5209e495415c90b21375686e1f8d65d9d2080d3d3aa2677344a48cbc1167e4e83bff6a27223e6f12d891a6416ad122b54ee0d36832dd08311
7
- data.tar.gz: 8b0fd57ee957d8eb8d58d3556f7f4ab145a7eb5cac82286c2cefd439a77bc28f6e03abe38484154b308b59b3b30e69bd749fec968fe053ebb0b4f9560ae0eb17
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
- rack_env = {
69
- "REQUEST_METHOD" => request.request_method,
70
- "PATH_INFO" => request.path_info,
71
- "QUERY_STRING" => request.query_string,
72
- "HTTP_VERSION" => request.env["HTTP_VERSION"],
73
- "REQUEST_URI" => request.url,
74
- "SERVER_NAME" => request.env["SERVER_NAME"],
75
- "SERVER_PORT" => request.env["SERVER_PORT"],
76
- "CONTENT_TYPE" => request.content_type,
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
- # Call lifecycle hooks: on_error
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
- log.error("an error occuring during the processing of a webhook event - #{e.message}")
145
+ # construct a standardized error response
134
146
  error_response = {
135
- error: e.message,
136
- code: determine_error_code(e),
147
+ error: "server_error",
148
+ message: "an unexpected error occurred while processing the request",
137
149
  request_id:
138
150
  }
139
- error_response[:backtrace] = e.backtrace unless config[:production]
140
- status error_response[:code]
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
@@ -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!("authentication configuration missing or invalid", 500)
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!("unsupported auth type '#{auth_type}'", 400)
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!("authentication failed", 401)
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: "/#{params[:path]}",
39
- handler: "DefaultHandler"
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 "request processed successfully with default handler (id: #{request_id})"
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
- (response || { status: "ok" }).to_json
70
-
85
+ response.to_json
71
86
  rescue StandardError => e
72
- log.error "request failed: #{e.message} (id: #{request_id})"
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
- # Return error response
92
+ # construct a standardized error response
75
93
  error_response = {
76
- error: e.message,
77
- code: determine_error_code(e),
78
- request_id: request_id
94
+ error: "server_error",
95
+ message: "an unexpected error occurred while processing the request",
96
+ request_id:
79
97
  }
80
98
 
81
- # Add backtrace in all environments except production
82
- unless config[:production] == true
83
- error_response[:backtrace] = e.backtrace
84
- end
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 error_response[:code]
104
+ status determine_error_code(e)
87
105
  content_type "application/json"
88
106
  error_response.to_json
89
107
  end
@@ -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
- error!("request body too large", 413)
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-time loaded only)
80
- begin
81
- handler_class = Core::PluginLoader.get_handler_plugin(handler_class_name)
82
- return handler_class.new
83
- rescue => e
84
- error!("failed to get handler '#{handler_class_name}': #{e.message}", 500)
85
- end
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" # Default version prefix for versioned signatures
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 BEFORE normalization to detect tampering
95
- unless headers.respond_to?(:each)
96
- log.warn("Auth::HMAC validation failed: Invalid headers object")
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
- # Security: Reject signatures with leading/trailing whitespace
111
- if raw_signature != raw_signature.strip
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
- provided_signature = normalized_headers[signature_header.downcase]
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 BEFORE normalization to detect tampering
65
- unless headers.respond_to?(:each)
66
- log.warn("Auth::SharedSecret validation failed: Invalid headers object")
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
- stripped_secret = raw_secret.strip
81
-
82
- # Security: Reject secrets with leading/trailing whitespace
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
- # Security: Reject secrets containing null bytes or other control characters
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
@@ -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.1.0".freeze
7
+ VERSION = "0.2.1".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.1.0
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